Compare commits

..

239 Commits

Author SHA1 Message Date
Patrick Buckley 5042b0cfdb chore: bump version to 1.6.0a2 2026-05-22 00:17:29 -07:00
Patrick Buckley 4117f45067 test(ci): allow whitespace before \( in insertAdjacentHTML lint clause (post-review)
Mirror the \s* posture used by the other unsafe-sink clauses (eval\s*\(,
Function\s*\(, setTimeout\s*\() so a regression like
``el.insertAdjacentHTML ("beforeend", x)`` — or a multi-line form with a
newline before the paren — still trips the lint.  The trailing ``HTML``
literal continues to discriminate against insertAdjacentElement and
insertAdjacentText.

Caught by Copilot review on #541.
2026-05-22 00:16:09 -07:00
Patrick Buckley 67ca3a5ba7 test(ci): broaden insertAdjacent-HTML lint, retire renderVerdictBadge carve-out
Extend _UNSAFE_CODE_SINK_RE with an `.insertAdjacent` + `HTML\(`
alternation so insertAdjacentHTML(...) is flagged across all 8 tracked
JS bundles.  The `HTML\(` suffix excludes insertAdjacentElement, which
takes a DOM node and is not an XSS sink — the five remaining sites in
ui/static/app.js (lines 170, 216, 328, 330, 1578) stay clear.

Retire the two carve-out paragraphs (file-level comment + function
docstring) that named ui/static/app.js's verdict-badge writers as the
reason the lint hadn't already broadened.  Commit 1 of this PR cleaned
both writers, so the carve-out is no longer load-bearing.

After this commit the DOM-cleanup arc (started in #532) is complete:
every unsafe-write sink family — inner/outer-HTML assignment (plain +
concat), insertAdjacentHTML, document.write, string-eval, dynamic-
Function, string-first-arg setTimeout/setInterval — is forbidden
across all 8 LLM-rendering bundles.
2026-05-22 00:16:09 -07:00
Patrick Buckley c3ddcd0b24 refactor(ui): renderVerdictBadge returns DocumentFragment, callers use appendChild
Rewrite the verdict-badge HTML builder from string-concat into DOM
construction (createElement + textContent + setAttribute + append).
The helper now returns a DocumentFragment of two top-level siblings
(.verdict-badge and .verdict-detail), which appendChild expands into
the parent — preserving the sibling-traversal invariants relied on by
Pane.updateVerdictBadge, toggleVerdictDetail, and the d-key keyboard
shortcut.

Inline onclick="toggleVerdictDetail(this)" replaced with an
addEventListener click handler; the non-arrow callback keeps the
`this`→button binding the old inline form had.

Both call sites (replayHistory + the live approval flow) swap from
el.insertAdjacentHTML("beforeend", X) to el.appendChild(X).

This is the last unsafe-write site in the DOM-cleanup arc started in
#532; commit 2 broadens the test_app_js.py lint regex to forbid the
insertAdjacent-HTML sink across all 8 tracked JS bundles.
2026-05-22 00:16:09 -07:00
Patrick Buckley dac541d304 fix(ui): close SSE connections on beforeunload to unblock multi-pane refresh (#539) 2026-05-21 11:54:05 -07:00
Patrick Buckley ba57d6f7c9 fix(ui): _paneCounter const-reassign + harden lint test (post-review)
The pre-push /review pass surfaced a second const-reassign that mirrors
the original `redacted` bug but in prefix-increment form:

  const _paneCounter = 0;             // turnstone/ui/static/app.js:10
  class Pane {
    constructor(wsId) {
      this.id = "p" + ++_paneCounter; // line 14 — TypeError at runtime
      …
    }
  }

`new Pane(...)` throws `TypeError: Assignment to constant variable.`
on every pane construction.  The first iteration of the const-reassign
guard in tests/test_app_js.py missed it because the regex matched
postfix `X++` / `X--` but not prefix `++X` / `--X`.

Two changes:

  1. Change `const _paneCounter = 0` to `let _paneCounter = 0` at
     turnstone/ui/static/app.js:10.  Same fix shape as the `redacted`
     bug — original walker tightened to const because its reassignment
     regex also only matched postfix forms.

  2. Extend the reassignment regex in test_swept_bundle_has_no_const_reassign
     to detect prefix `++X` / `--X` so a third repeat of this class
     can't ship.  Verified by injection: temporarily reverting (1)
     makes the new guard fire with a clear source-text diagnostic.

Quality polish on the same test (q-1/q-2 from the pre-push pass):

  - Failure message now prints the offending decl + reassignment line
    text alongside line numbers, so CI failures are self-contained
    (was: opaque tuples requiring two file-jumps to interpret).
  - Comment on `_SWEPT_BUNDLES` documents the maintenance contract
    (add only after sweeping; coordinator.js intentionally excluded).
2026-05-20 23:04:10 -07:00
Patrick Buckley 20895aa6e0 test(ci): pin var-free + const-reassign invariants across 7 swept JS bundles
After the var → const/let sweep, four guards keep the post-sweep state
honest in CI:

  1. node --check per bundle (parse-level smoke; catches a future edit
     that drops a brace or mis-balances a string before it reaches the
     browser).
  2. Static var-free assertion per bundle pins the keyword-swap result —
     any future `var X = …` in these 7 files fails CI loudly.
  3. Scope-aware static const-reassign guard per bundle.  For each
     `const X = …`, scans only the enclosing block (innermost { … } via
     brace tracking with regex/string/comment awareness) for X
     reassignments, so a same-named `let X` in an unrelated function
     doesn't false-positive against a `const X` in this one.  Catches
     the bug class that shipped through the original sweep:
     _redactApiKeys's `const redacted; redacted = …` threw TypeError
     at call-time, invisible to node --check.
  4. Runtime smoke for _redactApiKeys via `node -e` — calls the
     function with both query-string (`api_key=…`) and JSON
     (`"api_key": "…"`) shapes.  This is the bit that would have
     caught the actual shipped TypeError; (3) is the equivalent
     static check that catches the class without needing a runtime
     invocation.

Bundle list:
  - turnstone/ui/static/app.js
  - turnstone/console/static/admin.js
  - turnstone/console/static/governance.js
  - turnstone/console/static/app.js
  - turnstone/shared_static/auth.js
  - turnstone/shared_static/kb.js
  - turnstone/shared_static/utils.js

Verified by injection: temporarily reverting `let redacted` to
`const redacted` makes both guard (3) and guard (4) fail loudly.
2026-05-20 23:04:10 -07:00
Patrick Buckley 5053ab5611 refactor(console): scope-aware const-tightening pass on 3 swept bundles
Follow-up to the initial var-sweep commits.  The walker used a flat,
file-wide reassignment check to decide let vs const, which was
conservative when the same name appeared in multiple unrelated
scopes — e.g. `let i` as a loop counter in one function and an
unrelated `let i` reassigned in another would both stay `let`.
This second pass uses brace-tracking block-scope analysis (regex
literal aware) so tightening considers only reassignments within
the same block:

  - console/static/app.js:       +5 const  -5 let
  - console/static/governance.js: +15 const  -15 let
  - console/static/admin.js:     +26 const  -26 let

Mirrors q-2 from the /review pipeline.  ui/static/app.js was
tightened in the same way already in its sweep commit.

Mechanical; no behavioural change.
2026-05-20 23:04:10 -07:00
Patrick Buckley 14e0197e74 refactor(ui): ui/static/app.js — var → const/let sweep
754 line-start var + 50 for-loop counters converted: 663 const, 96 let
(line-start), plus 50 for-init let counters.

Hand-fix sites (surfaced by spike § 2):
  - 4 try-block hoists where the var was referenced from outside the
    try (var hoists out, let does not):
    - tryParseMedia()'s `obj`
    - _tryPrettyJson()'s `obj`
    - tryParseMcpError()'s `obj`
    - inline-plan render's `action` (used in the catch handler)
  - showNewWsModal() cleanup (was: 2 same-scope redeclarations):
    - submitBtn — first lookup at the top of the modal kept; the
      redundant re-fetch + duplicate textContent at the bottom
      dropped; submitBtn.disabled = false now sits as a bare
      property write
    - defaultOpt → renamed second occurrence to tplDefaultOpt
      (genuinely distinct DOM element — modelSelect vs tplSelect),
      both can be const

Post-review fix to the walker output:
  - _redactApiKeys(): the walker tightened `let redacted` to `const`
    but missed the `redacted = redacted.replace(...)` reassignment
    on the JSON-style pass.  Root cause was the walker's
    find_decl_extent not recognising JS regex literals — the
    unescaped " inside the character class [^&\s"] opened an
    in_str state that never closed on the same line, spilling
    the declaration span past `);` and pulling the reassignment
    line into the skip set.  The /review pipeline's bug finder and
    security finder both caught it (rendering would have thrown
    TypeError on every tool-output render).  Now `let redacted`.

Scope-aware const-tightening pass on top of the walker (mirrors q-2
from /review): 45 additional `let` → `const` flips where the walker
was conservative because the name happened to be reassigned in an
unrelated function elsewhere in the file.  Examples: `let pane` in
the 4 plan-dialog helpers; `let el` in the small Pane class methods.
Each tightening is verified safe by a brace-tracking block-scope
analysis (regex-literal aware).

Mechanical; no behavioural change.
2026-05-20 23:04:10 -07:00
Patrick Buckley 52d716658f refactor(console): admin.js — var → const/let sweep
744 line-start var + 87 for-loop counters converted: 606 const, 138 let.

Includes 2 multi-decl sites (counter accumulators at 3389 and 4021,
both `let` because the names are reassigned via += in the loop body),
and the spike-identified `indicator` redeclaration in `_toggleOidcPanel`
(now two `const indicator` declarations in disjoint block scopes —
inner if-block at 455 and function body at 482, so block-scoping makes
them independent).

Mechanical; no behavioural change.
2026-05-20 23:04:10 -07:00
Patrick Buckley d3f9e78715 refactor(console): governance.js — var → const/let sweep
470 line-start var + 64 for-loop counters converted: 337 const, 133 let.

Includes 2 multi-decl sites (`let url, method;` at 3951 and 4504 — both
uninitialised pairs that stay `let`) and two sibling `for (var k …)`
loops at lines 112/119 in the same function (now `for (let k …)` —
block-scoped to each loop init, no collision).

Mechanical; no behavioural change.
2026-05-20 23:04:10 -07:00
Patrick Buckley 8025d24b63 refactor(console): console/static/app.js — var → const/let sweep
270 line-start var + 5 for-loop counters converted: 232 const, 38 let.

Includes 3 multi-decl sites correctly handled:
- `let totalTokens, totalToolCalls, totalWs` (counter accumulators)
- `let mcpServers, mcpResources, mcpPrompts` (counter accumulators)
- `const au, bu` (sort comparator helpers — never reassigned)

The walker extends the const-tighten reassignment check across multi-line
declarations, so continuation lines (`bu = b.updated || 0,` belonging to
a `let au = …,` decl) aren't mis-counted as reassignments of `bu`.

Mechanical; no behavioural change.
2026-05-20 23:04:10 -07:00
Patrick Buckley 31261ddad2 refactor(shared): auth.js — var → const/let sweep
67 line-start var + 4 for-loop counters converted: 55 const, 12 let.

The 12 let cases are all genuine reassignments:
- Top-level state (`_loginBusy`, `_authMode`, `_refreshTimer`, etc.)
- `let delay` in `_scheduleRefreshAt` (clamped to min/max)
- `let data` inside `_tryRefresh` (assigned from inner try-catch)
- For-loop counters `let attempt`, `let i`

Mechanical; no behavioural change.
2026-05-20 23:04:10 -07:00
Patrick Buckley 1f43984bb5 refactor(shared): utils.js — var → const/let sweep
12 line-start var + 1 for-loop counter converted: 15 const, 1 let.
File previously had 4 const from the DOM-cleanup helpers; sweep finishes
the conversion.

Walker is scope-aware: when checking if name X is reassigned anywhere
in the file, lines that themselves declare X (`let X = ...`, function
parameters `(X)`, etc.) are skipped — `X = ...` in another scope is a
new binding, not a reassignment of the original.  This lets variables
like `min`/`hr` (declared inside two different formatter functions)
both become `const` correctly.
2026-05-20 23:04:10 -07:00
Patrick Buckley 6317026e66 refactor(shared): kb.js — var → const/let sweep
8 line-start var declarations converted: 6 const, 2 let.

Walker rules:
- var X = init → const X = init when X is never reassigned in the file
- var X = init → let X = init when X is reassigned (e.g. _kbPreviousFocus
  assigned in showKbHelp, html accumulated via +=)
- Reassignment check uses negative lookbehind to skip property writes
  (obj.X = ...).

Mechanical; no behavioural change.
2026-05-20 23:04:10 -07:00
Patrick Buckley bc49954c3a feat(reasoning): Phase 5 — vLLM Chat Completions reasoning-field replay (#537)
* feat(reasoning): Phase 5 — vLLM Chat Completions reasoning-field replay

Multi-turn CoT replay for vLLM-served reasoning models (Qwen3, DeepSeek-R1)
via the non-standard `reasoning` field on assistant messages. Closes the
PR #498 gap claiming Chat Completions has no replay surface — vLLM's
ChatMessage.reasoning input field is that surface (verified in
vllm/entrypoints/openai/chat_completion/protocol.py:54-64).

Session-level attach (no provider class changes). Three-gate composite:
provider isinstance OpenAIChatCompletionsProvider AND
server_compat.server_type == "vllm" AND operator-set
ModelConfig.replay_reasoning_to_model. Deliberately drops the
supports_reasoning_replay capability gate that protects Paths 1+2 —
vLLM's failure mode is silent (template-drop), not loud (server 400),
so the static gate would add operator friction without preventing the
silent failure. Server-type pin bounds blast radius — canonical OpenAI,
llama.cpp, sglang never see the non-standard field.

Also fixes a pre-existing _resolve_server_type bug: it read
cfg.capabilities.get("server_compat") but the model_registry loader pops
server_compat OUT of capabilities into the dedicated cfg.server_compat
dataclass field (model_registry.py:401, 485). Pre-fix the function
returned "" for every production ModelConfig, silently degrading PR #498
Path 3's synth-block source tag and would have made Phase 5 dead-on-
arrival. Test stubs across 3 files updated to mirror production shape
(empty capabilities + populated top-level server_compat) so the same
stub-drift can't hide future regressions.

The agent _run_agent path is deliberately excluded from Phase 5 hoists:
agent assistant messages don't carry _provider_content (rebuilt per
invocation from CompletionResult.content + tool_calls), so the helper
would no-op every turn. Comment at session.py inside _api_call documents
the exclusion.

OpenAI SDK version pin raised to >=2.37 to match the version verified
by the cross-boundary regression test
(test_reasoning_field_present_in_wire_body_when_attached) — drives a
real OpenAI client through httpx MockTransport and asserts the
non-standard field reaches the captured POST body, catching any future
SDK version that adds runtime field filtering.

Tests: 10 helper unit + 17 session integration (incl. SDK boundary
round-trip + per-gate negative tests + call-site wiring tests) + 2
audit-log discipline tests extending the PR #498 logging contract.

* docs(reasoning): apply PR #537 review on Phase 5 docstrings

Two nits from PR #537 review:

1. `_resolve_server_type` docstring claimed Phase 5 (`_maybe_attach_vllm_chat_reasoning`) called it; in fact Phase 5 reads `cfg.server_compat["server_type"]` directly off the single cfg it fetches for the operator-flag check, to avoid a second `registry.get_config` round-trip. Rewrite the paragraph: name `_maybe_synth_reasoning_block` as the sole caller (informational metadata for UI rehydration), then a separate paragraph noting Phase 5 reads the same field path directly and that both readers MUST stay aligned on changes.

2. `_maybe_attach_vllm_chat_reasoning` docstring referenced `project_reasoning_replay_capability_gate.md` which lives in personal memory store, not the repo. Replace the dead-link reference with an inline summary of the asymmetry rationale (Paths 1+2 keep the dual-gate because loud server-side failures; Path C drops the static gate because vLLM's failure mode is template-drop silent).
2026-05-20 22:41:40 -07:00
Patrick Buckley 90aa9e702e chore(ui): post-review hygiene — drop stale anchors, indent-agnostic test helper
Three quality findings from the multi-stage /review pass on the
preceding 4-commit class-refactor stack.  Bundled into one commit
because each is sub-20-line documentation/test-hygiene with no
behavioral surface.

1.  **Drop stale verdict-badge line numbers in tests/test_app_js.py.**
    Two comments cited ``ui/static/app.js:1287`` and ``app.js:1538``
    as the ``insertAdjacentHTML`` + ``renderVerdictBadge`` consumer
    sites.  The class refactor moved them to 1440 and 1655 (and any
    future nearby edit will move them again).  Drop the numbers; cite
    the helper name (``renderVerdictBadge`` / "the verdict-badge
    writers") instead.

2.  **Drop ``.prototype`` from 3 coord comment cross-refs.**
    ``coordinator.js:339, 439, 566`` referenced
    ``Pane.prototype.addUserMessage`` / ``addUserReminder`` /
    ``addToolReminder`` / ``replayHistory`` — but ``app.js`` has zero
    ``Pane.prototype.X`` after the refactor (it's all ``Pane.X``
    class methods now).  A reader following the breadcrumb hits a
    grep dead-end.

3.  **Introduce indent-agnostic _pane_method_offset() helper.**
    The four test slices switched from ``"Pane.prototype.X = function"``
    to ``"\n  X("`` in commits 3 + 4 — that's brittle against the
    deferred PR-B/C/D/E/F modernization (IIFE / module wrap shifts
    indent to 4 spaces, breaks all four slices silently with a bare
    ``ValueError``).  The new helper uses ``re.MULTILINE`` + ``\s{2,}``
    to match the method header at any leading-whitespace depth and
    ``assert``s on miss so a renamed method fails loudly at the
    pinning slice instead of further downstream.

    Replaces 8 ``body.index("\n  X(")`` call pairs across the 4 anchored
    tests (replayHistory ×3, appendToolOutput ×1).

Tests: 27/27 ``tests/test_app_js.py`` green.  No other suites touched.
2026-05-20 20:39:31 -07:00
Patrick Buckley 18d67e5fb3 refactor(ui): migrate last 5 Pane methods to ES6 class — refactor complete
Fourth and final commit of the ES6-class refactor (~/pane-class-refactor.md).
Migrates the remaining 5 prototype methods into the class block,
dissolving the last 4 `var self = this` workarounds, and updating the
appendToolOutput-anchored test in lockstep.

Methods migrated INTO the class body:

  showInlineToolBlock(items, autoApproved, judgePending)
  resolveApproval(approved, always, feedback, skipPost)
  appendToolOutput(callId, name, output, isError)
  sendMessage()
  cancelGeneration()

Two of these (`showInlineToolBlock`, `resolveApproval`) had multi-line
header decls; the conversion script joins their arg lines back into
a single-line class-method header.

Test anchor update (`tests/test_app_js.py`):

  body.index("Pane.prototype.appendToolOutput = function")
    → body.index("\n  appendToolOutput(")
  body.index("Pane.prototype.", start + 10)
    → body.index("\n  sendMessage(", start)

The new upper-bound anchors on the next class method's header (which,
by the source-file order preserved through the refactor, is
`sendMessage`).  The slice's inner assertions
(tryParseMcpError-before-renderToolOutput offset comparison) are
untouched — only the outer anchor pattern changes.

Final state:

  * `class Pane { ... }`: 1 declaration with 39 members
    (constructor + 38 methods)
  * `Pane.prototype.X = function`: 0 occurrences (was 38)
  * `var self = this`: 0 occurrences (was 16)
  * Arrow callbacks (`=>`): 55 (was 0)
  * 3 module-level helpers (_buildWatchResultBubble,
    _buildDefaultReminderBubble, _buildOutputWarningEl) cluster
    immediately after the class block.

The framing goal — "coord speaks a more modern JavaScript than
interactive" — collapses on this axis: Pane is now ES6 class shape
with arrow-function callbacks and `this`-lexical inner scopes, on par
with coord's ES6+ idioms.  The remaining var → const/let sweep and
template-literal pass are deferred to follow-up PRs B-F per §8 of the
refactor brief.

Tests: 27/27 `tests/test_app_js.py` + 258 broader (renderer + console
suites) green.  All 4 historically anchored test slices now use
class-method anchors and pass cleanly.
2026-05-20 20:39:31 -07:00
Patrick Buckley a087da1516 refactor(ui): migrate replayHistory + _attachRetryToLastAssistant, cluster helpers
Third of the four planned commits in ~/pane-class-refactor.md.
Migrates the two test-anchored history-rebuild methods into the
class block, updates the three pytest assertions that sliced them
by `Pane.prototype.X = function` literal, and relocates the last
nested module-level helper to live alongside the other two.

Methods migrated INTO the class body:

  replayHistory(messages)              — 304-line method, the largest single
                                         method in the file.  Dissolves
                                         2 of the remaining `var self = this`
                                         sites (the method-scope one + the
                                         inner-callback one inside the
                                         `tool` role branch's
                                         replayAdvisoriesAfterTool callback).
  _attachRetryToLastAssistant()        — small leaf method that the
                                         replayHistory tests use as the
                                         lower-bound sentinel for their
                                         slice.

Helper relocated to just after the class block:

  _buildOutputWarningEl(assessment)    — was nested between
                                         replayHistory's `};` and
                                         `_attachRetryToLastAssistant`'s
                                         header.  Joins the two helpers
                                         that already moved in commit 1
                                         (_buildWatchResultBubble,
                                         _buildDefaultReminderBubble) —
                                         all three module-level helpers
                                         now cluster immediately after
                                         the class.

Test anchor updates (`tests/test_app_js.py`):

  body.index("Pane.prototype.replayHistory = function")
    → body.index("\n  replayHistory(")
  body.index("Pane.prototype._attachRetryToLastAssistant", start)
    → body.index("\n  _attachRetryToLastAssistant(", start)

Three assertions touched: `test_replay_history_renders_content_before_tool_block`,
`test_replay_history_renders_persisted_verdict_badge`,
`test_replay_renders_user_interjection_advisory_after_tool_block`.
The slice's inner assertions (`msg.content`-vs-`msg.tool_calls` offset
ordering, `renderVerdictBadge` regex, `replayAdvisoriesAfterTool` +
`addUserMessage` substring matches) are untouched — only the outer
anchor pattern changes.

After this commit 5 prototype declarations remain: showInlineToolBlock,
resolveApproval, appendToolOutput, sendMessage, cancelGeneration —
all five migrate in commit 4 alongside the appendToolOutput test
anchor update.

Tests: 27/27 `tests/test_app_js.py` green.  Class block now spans
lines 12 → 1634; all 3 module-level helpers cluster at 1642 / 1676 /
1698 just after.
2026-05-20 20:39:31 -07:00
Patrick Buckley 228e80ced7 refactor(ui): migrate 9 callback-heavy Pane methods to ES6 class
Second of the four planned commits in ~/pane-class-refactor.md.
Migrates the callback-heavy methods that don't anchor any test
slice, dissolving 12 of the 16 `var self = this` workarounds into
arrow-function lexical-this along the way.

Migrated INTO the class body:

  _createDOM, connectSSE, handleEvent,
  _addUserMsgActions, _addRetryAction, _retryLast,
  _rewindToMessage, _startEdit, _editAndResend

Each migration applies the same mechanical transformation:

  * `Pane.prototype.X = function (args) {` header → class-method
    `X(args) {` form, body re-indented +2 spaces.
  * Every `var self = this;` declaration removed.
  * Every inner `function (...) {` callback rewritten as
    `(...) => {` — arrow functions inherit `this` lexically, so the
    outer-self capture pattern dissolves without behavioural change.
  * Every `\bself\b` identifier rewritten as `this`.
  * Closer `};` → `}` (no semicolon on class methods).

`sendMessage` and `cancelGeneration` are deliberately deferred to
commit 4 even though they're shape-eligible for this commit: they sit
AFTER `appendToolOutput` in the file, and
`test_phase8_appendtooloutput_dispatches_mcp_error_before_renderer`
slices `appendToolOutput` by looking for the next `Pane.prototype.`
declaration as an upper bound.  Migrating sendMessage + cancelGeneration
now would leave `appendToolOutput` as the LAST prototype declaration
in the file, breaking that slice.  Co-migrating all three in commit 4
keeps every intermediate commit green.

Spike-verified (§2.10.5): all 16 `var self = this` sites in app.js
are Type 1 (outer-`this` capture only — no event-target-`this`, no
delayed semantic capture).  Mechanical conversion is safe for every
site touched here.

Diff: 805 insertions / 816 deletions (net −11 lines) — the arrow
form is more compact than `function (args) {`, partly offsetting the
class-body indent overhead.

Tests: 27/27 `tests/test_app_js.py` + 238 broader (renderer + console
suites) green.  Anchored tests (replayHistory ×3, appendToolOutput ×1)
remain on their existing `Pane.prototype.X = function` literals —
their methods migrate in commits 3 + 4.
2026-05-20 20:39:31 -07:00
Patrick Buckley 738d425350 refactor(ui): scaffold class Pane, migrate 22 leaf methods to ES6 class
Opens the ES6 modernisation of ui/static/app.js (per the spike in
~/pane-class-refactor.md §2.10).  This first commit lays the class
scaffolding and migrates the 22 callback-free leaf methods — the
remaining 16 callback-heavy / test-anchored methods land in commits
2-4.

Migrated INTO the class body:

  constructor(wsId)  (was `function Pane(wsId)` at line 12)
  reset, updateWsName, disconnectSSE, setBusy,
  showEmptyState, removeEmptyState,
  addThinkingIndicator, removeThinkingIndicator,
  addSystemNudgeMarker, addUserReminder, addToolReminder,
  addUserMessage, getFeedback, appendToolOutputChunk,
  showOutputWarning, updateVerdictBadge, updateVerdictGlow,
  addInfoMessage, addErrorMessage, updateStatus,
  isNearBottom, scrollToBottom

Two module-level helpers (`_buildWatchResultBubble`,
`_buildDefaultReminderBubble`) were previously nested between leaf
methods.  Class bodies can't hold free function declarations, so they
relocate to immediately after the class closing `}`.  Function
declarations are module-hoisted so the relocation is semantically
free.

The other 16 prototype methods continue as `Pane.prototype.X =
function (...)` below the class — they each add to `Pane.prototype`
exactly as before, so the prototype shape is unchanged.

Spike-verified guardrails:

  * Hoisting: only `new Pane()` site is `createPane` at line 2152
    (renumbered), well after the new class block ends at line 458.
  * Strict mode: `app.js` is already clean of `with`,
    `arguments.caller`, `arguments.callee` — class bodies' implicit
    strict mode is a no-op.
  * Method enumerability: the 14 `for (var pid in panes)` loops
    iterate the module-level `panes` ID-map, not method names on an
    instance — class methods being non-enumerable on the prototype
    doesn't affect them.

No `var self = this` sites are touched in this commit — the 22 leaf
methods all have zero inner callbacks.  Commits 2-4 will dissolve the
16 var-self-this sites as their parent methods migrate.

Tests: 27/27 in `tests/test_app_js.py` green (test anchors on
`replayHistory` and `appendToolOutput` are untouched — commits 3 + 4
will co-migrate them with the assertion updates).
2026-05-20 20:39:31 -07:00
Patrick Buckley bc22dc5cb1 test(ci): extend zero-code-sink lint to console/static/app.js
Adds ``console/static/app.js`` to ``_UNSAFE_CODE_SINK_LINT_TARGETS``.
The parametrized scan now covers 8 static JS bundles — all admin-side
bundles are clean.

Docstring updates:

- Fully qualify the bundle paths in both posture lists
  (``ui/static/app.js``, ``shared_static/utils.js``, etc.) so the two
  ``app.js`` files are unambiguous now that both are in the targets
  list.
- ``console/static/app.js`` joins the **strict DOM-construction**
  list (alongside the interactive surface + shared helpers + coord
  chat entry) — the cluster-dashboard renderer is now full
  createElement / textContent construction, no HTML strings ever
  interpolated.
- ``console/static/admin.js`` + ``console/static/governance.js``
  remain in the **sink-free string-concat** list — they retain the
  escapeHtml + concat builder shape with the unsafe sink off the call
  site.

The ``insertAdjacent`` carve-out note (verdict-badge writers in
``ui/static/app.js``) is now qualified to avoid ambiguity.
2026-05-20 19:04:46 -07:00
Patrick Buckley 7f850c3acb refactor(console): full DOM-construction for cluster dashboard renderer
Initial AST-light swap routed 12 ``innerHTML =`` sites through
``setSafeHtml`` (8 sites) or ``replaceChildren()`` (4 empty-string
clears).  The /review pipeline flagged the node-table render path as
a hot loop where DOMParser-per-row costs scale with cluster size (the
project's 100-node design ceiling × per-RAF render frame on SSE
churn), and the static colHeaders literal was being re-parsed every
render.

This commit lifts the entire console-dashboard renderer to true
``createElement`` + ``textContent`` + ``append`` construction —
matching the strict-posture lane used by ``ui/static/app.js`` rather
than the sink-free string-concat posture admin/governance use.
Net result: **zero** ``innerHTML`` and **zero** ``setSafeHtml`` calls
remain in ``console/static/app.js``.

Three new module-local helpers carry the heavy structural fragments:

- ``buildColHeaders()`` returns a DocumentFragment with the 7-span
  column-header layout used at the top of the table and inside each
  multi-node group body.  Built once via createElement so the static
  literal isn't re-parsed every render — and the duplicated literal
  between the top-table and per-group sites collapses into one helper.
- ``_buildNodeNumCell(value, highlighted, cellClass)`` — the
  ``<span class="X num [has-value]">N</span>`` shape used 8× across
  buildNodeRow and the group header.
- ``_buildHealthCell(cellClass, healthPct, healthFillClass)`` — the
  health-bar trailing cell used both per-row and per-group.

The 4 ``setSafeHtml`` sites that remained after the initial sweep
(error-state placeholders + state-pill builder) also flip to
``createElement`` / ``makeEmptyState`` for consistency with the rest
of the file's new posture.  ``makeEmptyState`` is the helper added
during the interactive cleanup (#532).
2026-05-20 19:04:46 -07:00
Patrick Buckley 408ec9222d chore: apply Copilot review on #534 — dead branch + float escapes
Two Copilot findings, both pre-existing on main since 2026-04-05 but
preserved by this PR's mechanical refactor.  Addressing them here
since they're appropriately in scope (the renderJudgeSettings
function is the focus of the refactor) and Copilot ranked them high.

- **Dead ``shortKey === "model"`` branch removed.**  The loop at the
  top of ``renderJudgeSettings`` does ``if (s.key === "judge.model")
  continue;`` because ``judge.model`` is rendered by the cross-cutting
  model-alias picker in ``admin.js`` (line 4895, ``aliasKey: "judge.
  model"`` registry entry).  No other setting key has the form
  ``judge.X`` where ``X === "model"``, so the conditional branch was
  provably dead — Copilot's confusion ("operators won't see a model
  picker") is the same confusion a future reader would hit.  Drop
  the branch + the corresponding ``SELECT``-dispatch in the binding
  loop (no SELECT inputs remain in this renderer).

- **Float-input value/min/max now escapeHtml'd.**  Previously
  ``currentVal`` and ``s.min_value`` / ``s.max_value`` were
  interpolated raw into ``value="..."``, ``min="..."``, ``max="..."``
  attributes.  Numbers stringify safely, but
  ``admin_list_judge_settings`` can fall back to returning a raw
  stored string when deserialization fails (Copilot's flag) — a
  non-numeric fallback containing a ``"`` would break out of the
  attribute boundary.  Wrap with ``escapeHtml(String(...))`` for
  defense-in-depth + null-safety.
2026-05-20 19:01:05 -07:00
Patrick Buckley daba8fb15d test(ci): extend zero-code-sink lint to governance.js
Adds ``console/static/governance.js`` to ``_DOM_WRITE_LINT_TARGETS``.
The parametrized scan now covers 7 static JS bundles.  Docstring
updated: governance.js joins admin.js in the "sink-free string-concat"
posture; ``console/static/app.js`` (cluster dashboard / node table) is
the last admin-side bundle still pending — same posture once cleaned.
2026-05-20 19:01:05 -07:00
Patrick Buckley 8720c5c11c refactor(console): clean governance.js — innerHTML sinks, inline handlers, stale comments
Same posture sweep as the admin DOM cleanup (#533) applied to
turnstone/console/static/governance.js:

1. **46 innerHTML sites → setSafeHtml**.  Mechanical swap via the
   same AST-light Python walker the admin PR used.  HTML strings are
   still built with escapeHtml + concat — same defence as before, just
   no innerHTML sink at the call site.  `node --check` clean; prettier
   formatted.

2. **6 inline event handlers in renderJudgeSettings refactored to
   delegated bindings**.  The previous code embedded the setting key
   as a JS-string inside an HTML attribute
   (`onclick="saveJudgeSettingFromInput('KEY')"`), the same footgun
   addressed in admin.js: escapeHtml turns `'` into `&#39;`, but the
   HTML parser decodes that before the JS parser runs, so a key with
   an apostrophe would escape the JS string.  Keys today come from a
   static judge-settings registry without apostrophes, so no live
   vuln — but the pattern is brittle.

   Inputs now carry a single `data-judge-key`; the binding loop
   dispatches on `this.type === "checkbox"` / `this.tagName ===
   "SELECT"` to wire change-listeners for the auto-save inputs, and
   leaves text/number/password inputs alone (they commit via the
   adjacent Save button).  Save and Reset buttons carry their own
   `data-judge-save-key` / `data-judge-reset-key` and bind via click
   delegation — mirrors the admin.js settings-tab shape.

3. **1 inline handler in audit pagination converted** for
   consistency: `<button onclick="loadMoreAudit()">` → addEventListener
   after setSafeHtml.

4. **4 stale safety-narrator comments stripped** ("// values escaped
   via escapeHtml above", "// NOTE: innerHTML usage below is safe",
   etc.).  The safety now lives in setSafeHtml; the per-call-site
   narration is tombstone-shaped and removed per the project
   no-tombstone-comments convention.

5. **`saveJudgeSettingFromInput` hardened**.  The
   `document.querySelector('[data-judge-key="' + key + '"]')` lookup
   now wraps the key in `cssEscape` (from shared/utils.js) so a
   future key containing `"` or `\` doesn't break the selector.

console/static/app.js (12 sites) remains pending — same posture once
cleaned, separate PR.
2026-05-20 19:01:05 -07:00
Patrick Buckley 588e9c8463 chore: apply Copilot review on #533 — terminology + constant rename
Two small follow-ups from the Copilot PR review:

- ``admin.js:3112-3115`` — the docstring on ``_onSettingChange`` said
  ``inp`` is "passed in by the delegated handler", but the wiring at
  the call site is a per-element ``addEventListener`` rather than a
  single delegated handler on the container.  Reword to
  "per-input event-listener callback" so a future reader doesn't
  read "delegated handler" and refactor under that mistaken premise.

- ``test_app_js.py`` — ``_DOM_WRITE_LINT_TARGETS`` constant name
  was missed in the earlier sweep that renamed ``_UNSAFE_DOM_WRITE_RE``
  → ``_UNSAFE_CODE_SINK_RE`` and the test to
  ``test_no_unsafe_code_sinks_in_static_assets``.  Rename to
  ``_UNSAFE_CODE_SINK_LINT_TARGETS`` so the three names align.
2026-05-20 18:34:17 -07:00
Patrick Buckley 30273b73f2 test(ci): broaden DOM-write lint to dynamic-code sinks
Extend ``_UNSAFE_DOM_WRITE_RE`` to also flag the JS string-to-code
constructors that share the same XSS / RCE-on-injection threat model
as innerHTML:

- ``eval(...)`` — string-eval
- ``new Function(...)`` — dynamic-Function constructor
- ``setTimeout(string, ...)`` / ``setInterval(string, ...)`` — the
  string-first-arg form (function-first-arg remains unflagged)

Verified that none of these sinks exist in the six currently-scanned
files (interactive app.js + shared utils/auth/kb + coord chat entry +
console admin.js).  Pre-existing parametrized test
``test_no_unsafe_dom_writes_in_static_assets`` extends naturally to
the broader pattern; all 25 cases pass.

``insertAdjacent`` + HTML continues to be excluded — two existing
verdict-badge sites in ui/static/app.js consume
``renderVerdictBadge``'s HTML-string output, so broadening that
specific sink first needs the upstream helper cleaned.
2026-05-20 18:34:17 -07:00
Patrick Buckley e2988713b3 refactor(console): replace inline event handlers with delegated bindings
The settings panel renderer in admin.js previously emitted inline
``onclick``/``onkeydown``/``oninput``/``onchange`` attributes that
embedded the setting key as a JS-string inside an HTML-attribute
context:

    '<button ... onclick="_saveSettingValue(\'' + escapedKey + '\')">'

``escapeHtml`` escapes apostrophes to ``&#39;``, but the HTML parser
decodes that *before* the JS parser runs — so a key containing an
apostrophe would break out of the JS string.  Today the keys come
from a static settings registry without apostrophes, but the pattern
is brittle: a future maintainer adding operator-controlled values to
the attribute would discover the footgun the hard way.

Every inline handler in admin.js is now replaced with a delegated
``addEventListener`` set up after ``setSafeHtml(container, html)``.
Handlers read their context off ``data-*`` attributes (which
``setAttribute`` correctly escapes), so the HTML-attribute /
JS-string double-context is eliminated.

Touched renderers:

- ``_renderSettings`` — section headers, help buttons, per-key inputs
  (input/change), per-key save + reset buttons
- ``_renderNodeMetadata`` — section headers (delete/add buttons
  already used delegation)
- MCP install source selector — radio-change handler for
  ``_updateInstallFields``

The named handlers (``_saveSettingValue``, ``_toggleSettingsSection``,
etc.) are unchanged in signature and behaviour; only their wiring
moved from inline-attribute to ``addEventListener``.
2026-05-20 18:34:17 -07:00
Patrick Buckley 04a336acce test(ci): extend zero-DOM-write lint to admin.js, parametrize per file
Two changes to ``tests/test_app_js.py``'s DOM-write lint:

1. Add ``console/static/admin.js`` to the scan target list.  Now
   covers all 6 static JS bundles that render LLM output, tool
   results, operator-supplied data, or user input.  ``governance.js``
   and ``console/static/app.js`` remain pending follow-ups (will land
   as separate cleanup PRs).

2. Parametrize the lint test over the target list.  Each file is now
   its own pytest case (e.g.
   ``test_no_unsafe_dom_writes_in_static_assets[turnstone/console/static/admin.js]``),
   so a failure attributes precisely to the offending file instead of
   masking offenders behind the first-file's assertion.

   Rename the test from ``..._in_interactive_assets`` to
   ``..._in_static_assets`` — the coverage now spans more than the
   interactive surface, and the surface-neutral name leaves room for
   governance.js / console-app.js without another rename.

3. Restructure the docstring to surface the two distinct postures
   (strict DOM-construction surfaces vs. sink-free string-concat
   admin.js) up front, instead of burying the admin caveat after the
   main contract claim.
2026-05-20 18:34:17 -07:00
Patrick Buckley 421ea845ed refactor(console): route admin.js innerHTML sinks through setSafeHtml
48 sites in turnstone/console/static/admin.js previously assigned
HTML strings directly to .innerHTML.  Every site is now routed
through the shared setSafeHtml helper (added in the interactive
cleanup PR), which parses the trusted HTML via DOMParser and installs
the result via replaceChildren — no innerHTML sink at the call site.

Two distinct postures across the admin pages:

- 47 sites: ``setSafeHtml(el, html_built_with_escapeHtml)`` — admin
  builders construct HTML strings via string concatenation, running
  every interpolated value through escapeHtml first.  Defence still
  depends on escapeHtml at the builder; the lint catches the sink but
  cannot catch a missing escape.  Full DOM-construction rewrites
  (createElement + textContent) would be structurally safer but are
  out of scope — 136 escapeHtml call sites + several thousand lines
  of builder code is a separate effort.
- 1 site: ``srcEl.replaceChildren()`` for the MCP-install package
  panel's empty-state branch — equivalent to the old
  ``srcEl.innerHTML = ""`` clear, slightly more idiomatic.

No user-visible behaviour change.  DOMParser parses the same HTML the
prior innerHTML assignment did; the new DOM is identical, and the
container.querySelectorAll("[data-X]") event-binding pattern still
finds the newly-installed nodes the same way it did before.

console/static/governance.js (46 sites) and console/static/app.js
(12 sites) remain pending follow-ups.  The verdict-badge writers'
two insertAdjacentHTML sites in ui/static/app.js still need the
upstream helper cleaned first — separate effort.
2026-05-20 18:34:17 -07:00
Patrick Buckley 13a2df3bc9 chore(ci): ignore disputed PYSEC-2025-183 in pip-audit
The pyjwt 2.12.1 advisory (\"weak encryption\") is disputed by the
supplier — the key length is the calling application's
responsibility, not the library's.  Turnstone generates its JWT
signing keys via the standard ``secrets`` module at
operator-controlled strength (see ``turnstone/core/auth.py``), so the
advisory does not apply to this codebase.

No fix version is available — pyjwt 2.12.1 is the current PyPI
latest as of 2026-05-21.  Adding ``--ignore-vuln PYSEC-2025-183``
with the rationale documented in-line so a future reviewer can
re-evaluate when an upstream fix or a non-disputed re-issue lands.

The advisory was published between main's last CI pass (2026-05-19)
and the interactive-cleanup PR's CI run (2026-05-21); main's
security job will fail next push without this fix.
2026-05-20 17:51:46 -07:00
Patrick Buckley 30c7e04e15 test(ci): tighten DOM-write lint — catch += and multi-line sinks
Two Copilot-suggested improvements to the regression scan:

- Allow optional ``+`` before ``=`` in the regex so a future
  regression that switches sinks from ``el.innerHTML = X`` to
  ``el.innerHTML += X`` is still caught.  The trailing ``(?!=)``
  negative-lookahead still excludes ``===`` / ``==`` reads.
- Switch the scan from line-by-line ``splitlines()`` iteration to a
  whole-body ``finditer`` so ``\\s*`` can span newlines.  Multi-line
  sinks like ``el.innerHTML\\n  = X`` (an artifact of formatter
  line-wrapping at the assignment) are now caught.  Match positions
  map back to line numbers for the failure message.

Verified locally with representative test cases including
``el.innerHTML += X``, ``el.outerHTML += X``, multi-line variants,
and the ``===`` / ``==`` reads that must remain unflagged.
2026-05-20 17:51:46 -07:00
Patrick Buckley 05960be2e5 test(ci): pin zero direct-HTML-assignment across interactive surfaces
Adds two regression tests in tests/test_app_js.py:

- test_no_unsafe_dom_writes_in_interactive_assets: whole-file scan
  for inner/outerHTML assignment + doc-write sinks across all five
  interactive surfaces (app.js, shared utils/auth/kb, coord chat).
  Includes line + content in the failure message so a regression
  fails loudly with location.

- test_shared_utils_defines_set_markdown_helper: pins setMarkdown's
  signature and the DOMParser path so a refactor that drops the
  parser (e.g. swap to Range.createContextualFragment) forces an
  explicit reviewer decision.

The lint regex is tightened with a negative-lookahead so equality
comparisons (``===`` / ``==``) don't false-positive, and broadened
to cover ``outerHTML`` and the legacy doc-write sink in addition to
``innerHTML``.  ``insertAdjacent`` + HTML is *not* covered yet — two
existing verdict-badge sites (app.js:1287, 1538) consume the HTML
output of renderVerdictBadge and would need that helper cleaned
first.
2026-05-20 17:51:46 -07:00
Patrick Buckley 6e9e995bbe refactor(shared): route coord chat + auth/kb overlays through setSafeHtml
Three adjacent sites that all assign pre-trusted HTML strings (built
from escapeHtml + static template literals, no caller-supplied raw
HTML) get the same DOMParser + replaceChildren treatment via the
shared setSafeHtml helper:

- coordinator.js:327 (appendMsg's body) — callers pass either
  esc(text) or renderToolOutput(...) output, both pre-escaped.
- auth.js:302 (login overlay) — _buildLoginHTML() returns a static
  template with no caller-supplied interpolation.
- kb.js:30 (keyboard-help overlay) — html is built from a static
  keys-and-bindings table.

Eliminates the only remaining innerHTML site in coord and the two
shared-overlay sites.  Console admin / governance JS bundles
(106 sites in console/static/{app,admin,governance}.js) remain
outside this PR — different threat model (admin-only behind auth
gate), separate effort.
2026-05-20 17:51:46 -07:00
Patrick Buckley d8901fd01b refactor(ui): DOM-construct all 26 innerHTML sites in app.js
Routes every direct-HTML assignment in turnstone/ui/static/app.js
through the helpers added in the previous commit, or through native
DOM construction (createElement + textContent + append /
replaceChildren).  Net result: zero ``.innerHTML =`` sites in app.js.

Breakdown of the 26 sites:

- 2 renderer-output sites (replayHistory message body, plan-inline
  body) now use setMarkdown — DOMParser keeps the audit at zero
  innerHTML sites, stricter than the prior centralise-not-eliminate
  plan.
- 8 empty-string clears (pane reset, layout rebuild, dashboard
  refresh, etc.) become replaceChildren().
- 5 keyboard-shortcut button labels (y/n/a/Esc) collapse onto
  makeKeyLabel(hint, label).
- 5 dashboard placeholders (Loading / Failed / No active workstreams)
  use makeEmptyState(text).
- 6 escapeHtml-interpolated HTML strings (command preview, judge
  evidence, dashboard state cells, footer node, diff lines) become
  createElement + textContent + append; escapeHtml drops out because
  textContent escapes intrinsically.

No user-visible behaviour change — DOMParser parses the same HTML
the prior innerHTML assignment did, and DOM construction with
textContent produces equivalent rendered output.  Mermaid / hljs
post-render scope is unchanged (now scoped to the body element
rather than the wrapper for the two setMarkdown sites; both
contain the same code blocks).
2026-05-20 17:51:46 -07:00
Patrick Buckley 40ed122689 feat(shared): add DOM-construction helpers in utils.js
Adds four helpers that move the unsafe HTML-string sinks off the call
site:

- setSafeHtml(el, html): parses a trusted HTML string via DOMParser
  and installs the result via replaceChildren — no innerHTML.
- setMarkdown(el, content): renderMarkdown -> setSafeHtml ->
  postRenderMarkdown (hljs + mermaid).
- makeEmptyState(text): builds a <div class="dashboard-empty"> card.
- makeKeyLabel(hint, label): keyboard-hint + label fragment for
  approve/deny/always/amend/reject buttons.

The helpers are unused at this point; subsequent commits route the
26 app.js sites, coord:327, auth.js, and kb.js through them.
2026-05-20 17:51:46 -07:00
Patrick Buckley ac32d92465 docs(changelog): note admin config.toml support + load_config perm warning 2026-05-19 08:11:45 -07:00
Patrick Buckley b6935004ed feat(admin): align turnstone-admin DB config with server (config.toml + env) (#531)
* feat(admin): align turnstone-admin DB config with server (config.toml + env)

turnstone-admin previously read TURNSTONE_DB_* env vars only, forcing
operators with credentials in config.toml to re-export them just to
run admin commands. Wire add_config_arg + apply_config(["database"])
into main() so admin honors the same precedence as turnstone-server:
CLI / config.toml [database] > TURNSTONE_DB_* env > hardcoded defaults.

Also exposes pool_size + sslmode/sslrootcert/sslcert/sslkey to admin,
which previously dropped any such config silently.

Hardening: load_config() now warns once when config.toml is group- or
world-readable, since DB password and TLS key paths live in [database].

Tests cover precedence (default / config / env / partial fallback /
empty-string-in-config-beats-env), the real init_storage boundary on
a tmp sqlite path, the sys.argv -> main() pre-parser path, and the
new permission check (mode 0644 warns, 0600 quiet).

* test(admin): unify config import style in test_admin_db_config

Use module alias (config_mod.apply_config) instead of mixing
'import turnstone.core.config as config_mod' with 'from
turnstone.core.config import apply_config'.  Addresses
github-code-quality bot feedback on PR #531.
2026-05-19 08:09:33 -07:00
Patrick Buckley 88d4a94b1e chore: bump version to 1.6.0a1 2026-05-18 21:06:53 -07:00
Patrick Buckley 85c1d7137d docs(changelog): release 1.5.17 notes 2026-05-18 21:02:52 -07:00
Patrick Buckley 13ed62d200 fix(judge): UPSERT intent_verdicts so llm_fallback upgrades land
Async LLM-tier "llm_fallback" verdicts (judge.py:1073, judge.py:1131
via _deliver_fallbacks) deliberately reuse the heuristic verdict's
``verdict_id`` so the row gets "upgraded in place" from heuristic →
llm_fallback when the LLM judge times out, is cancelled, or returns
no content.  The consumer ``_persist_intent_verdict`` was doing a
plain INSERT via ``create_intent_verdict``, hitting the
``intent_verdicts_pkey`` constraint on every llm_fallback delivery.
Postgres logged the duplicate-key error; the application try/except
swallowed it at log.debug — so the row never actually got upgraded
and the LLM judge's annotation ("(LLM judge did not return a
verdict)") was lost.

The collision rate exploded on stable/1.5 smoke tests because
PR #527 (just merged) added two new heuristic-INSERT paths in the
auto-approve early-return branches of ``approve_tools`` — previously
those branches dropped heuristic verdicts on the floor, leaving no
row for the fallback to collide with.

Fix:
- New ``upsert_intent_verdict`` method on the storage protocol +
  sqlite + postgres impls, using dialect-specific
  ``insert(...).on_conflict_do_update(index_elements=["verdict_id"],
  set_={...})``.  Set_ clause updates ONLY the three fields that
  genuinely change between heuristic and llm_fallback: ``tier``,
  ``reasoning``, ``judge_model``.
- Every other column is excluded from set_: identity columns
  (verdict_id, ws_id, call_id, func_name, func_args), carried-
  verbatim columns (intent_summary, risk_level, confidence,
  recommendation, evidence, latency_ms), and ``user_decision``.
- ``user_decision`` exclusion is load-bearing: ``IntentVerdict
  .to_dict()`` doesn't project it, so a fallback verdict reaching
  ``_persist_intent_verdict`` carries the kwarg's ``"pending"``
  default.  If the operator already resolved the approval between
  heuristic INSERT and fallback delivery, the row's user_decision
  has been stamped to ``"approved"``/``"denied"``/``"timeout"`` (or
  an auto-approve reason at heuristic-INSERT time per PR #527).
  Including ``user_decision`` in set_ would silently clobber that
  back to ``"pending"``.
- ``_persist_intent_verdict`` switched from ``create_*`` to
  ``upsert_*``.  Bulk path ``create_intent_verdicts_bulk`` stays as
  plain INSERT — every heuristic ``verdict_id`` is freshly minted
  in ``judge.evaluate`` so in-turn dups can't happen.  The inverse
  race (daemon-judge verdict lands BEFORE the bulk write) IS
  reachable today but its observable behavior is unchanged by the
  per-row UPSERT switch; documented at the bulk site for a future
  hardening pass.

Test coverage:
- TestIntentVerdictUpsert × 4 — fresh-id insert, conflict-upgrade,
  user_decision preservation across heuristic→approved→fallback,
  identity + carried-field preservation.
- Existing tests in test_session_ui_base.py updated to mock the
  new upsert method instead of create_intent_verdict.
2026-05-18 21:00:24 -07:00
Patrick Buckley aa076fbbb5 fix(coord): drop unused snip-threshold constants, name elision margin
Two dead module-level constants flagged by github-code-quality on
PR #529: ``_INSPECT_MSG_SNIP_THRESHOLD`` and
``_INSPECT_TOOL_ARG_SNIP_THRESHOLD`` lost their callers when the
content-snip logic moved into the ``_snip_head_tail`` helper.  The
helper now reads ``head + tail + _INSPECT_ELISION_MARGIN`` so the
"reserve bytes for the elision marker" rationale that the dead
constants documented stays named instead of becoming a bare ``64``.
2026-05-18 19:35:25 -07:00
Patrick Buckley d2f5db092d feat(coord): three-tier compression for inspect_workstream output
A coord doing a fan-out wave of inspect_workstream calls against
tool-heavy children could blow the context budget on raw output
alone (one child with a 100 KB bash result × N children).  The
previous safety net was ``_truncate_output``'s head+tail strategy,
which silently drops *middle* messages — exactly the wrong shape
for a coordinator trying to understand a child's trajectory (the
LAST message tells the model what the child concluded; the FIRST
sets the brief; the middle is the connective tissue).

Three-tier degradation modeled on the search tool's pattern at
``session.py:_format_search_results``:

  Tier 1 (full):    every message verbatim — used when size fits.
  Tier 2 (compact): per-message head/tail-snipped content (600/300
                    chars) plus snipped ``tool_calls.arguments``
                    (300/100 chars).  When content snipping alone
                    doesn't fit, fall through a message-list trim
                    ladder ((20,30) → (10,20) → (5,10)) that keeps
                    head + tail messages and elides the middle as
                    ``{"_omitted": N}``.
  Tier 3 (skeleton): no messages — counts + role distribution +
                    verdicts-by-risk + last assistant preview.

Budget 32 KB (matches ``_SEARCH_OUTPUT_BUDGET``).  First emission
whose JSON serialization fits the budget wins.  ``_tier`` lands on
every non-error emission so the coordinator LLM and audit readers
can see which compression rung was selected; ``_tier_note`` carries
actionable advice (re-call with a smaller ``message_limit`` etc.).
Error-shape results bypass tiering — they're already small.

Bug fixes caught during review:
- ``_compact_message`` now preserves the assistant-side ``tool_calls``
  list with snipped ``function.arguments``; the pre-fix shape left
  audit readers with tool-result orphans against invisible calls.
- The intermediate Tier-2 list-trim ladder fixes a size-monotonicity
  bug where Tier-2 with un-snippable content (per-message body
  under the 964-char threshold) plus the added ``_tier_note`` came
  out STRICTLY larger than Tier-1, falling through to skeleton
  when a head+tail trim would have preserved dozens of messages.
- ``_inspect_skeleton`` reads ``result["skill_id"]`` (production
  storage row key) with a ``skill`` fallback; pre-fix it read
  ``skill`` only and emitted ``null`` for every real workstream.
2026-05-18 19:35:25 -07:00
Patrick Buckley c4495c0c48 fix(coord): address PR review threads on spawn_workstream rename
Three Copilot threads from PR #526:

1. ``_exec_spawn_workstream`` success path emitted
   ``{"child_ws_id": null}`` when the upstream response unexpectedly
   omitted ``ws_id`` (200-shape with no error field, no id field).
   Adds the missing guard — mirrors ``_exec_spawn_batch`` which
   already surfaces ``"spawn returned no ws_id"`` as a denied row.
   The LLM now sees a tool error and can retry instead of chasing
   a null id through follow-up tools.

2. ``docs/coordinator-skills.md`` UI render note said "keep the
   ws_id as the click-through key" in a paragraph that had just
   introduced ``child_ws_id`` — readable as "the ws_id value" but
   confusable as a field-name claim.  Clarifies that the value
   class is the same regardless of which key carried it.

3. ``docs/bulk-endpoints.md`` ``spawn_batch`` example shows
   ``child_ws_id`` (coord-tool output shape).  The doc title and
   the "model tool" column label already disambiguate it from HTTP
   API responses, but a reader landing at the example section
   directly could miss the framing.  Adds one explicit sentence.
2026-05-18 19:35:10 -07:00
Patrick Buckley 6948ea21cb fix(coord): rename ws_id->child_ws_id in spawn return JSON
Coordinator LLMs on large fan-outs recency-bias on seeing `ws_id`
in a `spawn_workstream` / `spawn_batch` return -- calling
`spawn_workstream(ws_id=...)` again instead of progressing to
`wait_for_workstream(ws_ids=[...])`. On 10+ child fan-outs this
cascades into self-inflicted re-spawn loops.

Rename to `child_ws_id` (already an existing project term -- see
`tasks` tool, `child_event_bus.py`) defuses the recency bias.
Scope is the LLM-facing JSON only -- the server HTTP API at the
spawn endpoint still returns `ws_id`, and the internal reads of
that HTTP response are unchanged.

Also updates the two tool descriptions, the operator-facing skill
doc, and the bulk-endpoints example so docs don't undo the rename.
2026-05-18 19:35:10 -07:00
Patrick Buckley 99a309ed70 fix(coord): omit empty allowed_tools in list_skills + clarify semantics
A skill with `allowed_tools=[]` in the coordinator's `list_skills`
response read as "no tools are usable by this skill" to a model that
didn't know the semantics — but the actual meaning is "no tools are
pre-approved for auto-approval (auto-approve exemption list)".  Real
misdiagnosis incident: a code-review child appeared to have been
spawned with zero tool access when in fact the skill simply hadn't
declared an auto-approve allowlist.

Two-part fix:
- `coordinator_client.list_skills` omits the `allowed_tools` key from
  the per-skill dict when empty.  Absence now carries the unambiguous
  meaning "no tool is pre-approved for this skill"; presence (with a
  non-empty list) keeps the standard Claude Code skill-spec shape.
- `turnstone/tools/list_skills.json` description rewrites the field
  doc so the LLM sees: "tool names exempt from the operator approval
  gate ... the field is OMITTED when empty: a skill without
  `allowed_tools` still has access to every tool in its session's
  toolset; absence of the field means no tool is pre-approved for
  this skill, not that the skill has no tools."

Field name stays `allowed_tools` — matches the upstream Claude Code
skill frontmatter (`allowed-tools` hyphenated, stored as
`allowed_tools` internally per `skill_parser.py:241-242`).  Parser,
storage column, admin UI, and SDK unchanged.
2026-05-18 19:33:28 -07:00
Patrick Buckley 59c116f9eb fix(judge): explicit user_decision vocabulary (no more empty strings)
Auto-approved tool calls left intent_verdict rows with `user_decision=""`,
indistinguishable from rows still pending manual review.  Real misdiagnosis
incident: a coord with `recommendation="review"` and `user_decision=""` was
read as "stuck waiting for approval" when in fact the tools had been
auto-approved and the child was running normally.

New vocabulary at the storage API boundary (column server_default stays
`""` so pre-fix legacy rows are still distinguishable as such):

- `pending`           — at insert, before any resolution
- `approved` / `denied` — manual user resolution
- `timeout`           — approval-event timeout (split from `denied` so the
                        audit column alone tells them apart; the feedback
                        string used to carry this distinction)
- `policy` / `blanket` / `skill` / `always` / `auto_approve_tools` —
                        auto-approve reasons (mirror `AutoApproveReason`)

Heuristic verdicts on the two auto-approve early-return branches are now
persisted with `user_decision=<reason>` (previously dropped on the floor).
Late LLM verdicts for already-auto-approved call_ids look up the reason via
a TTL-pruned `_auto_approve_reasons` map (lazy 60s prune at write time, so
no fixed cap can silently regress the fix on the N+1th auto-approve; LLM-
disabled sessions don't leak entries because prune fires whenever auto-
approves happen).

Bug fixes caught during review:
- `on_intent_verdict` early-returns when the verdict already carries an
  auto_reason — without this, a manual `resolve_approval` on a mixed batch
  would overwrite the auto-stamped row with `approved`/`denied`.
- `_record_auto_approves` runs BEFORE `_persist_auto_approved_heuristic_*`
  so the lookup map is populated before any concurrent LLM verdict can
  fire and miss it.
- `resolve_approval(timeout=True, approved=True)` now raises ValueError
  to make the split-brain shape unrepresentable.
- Approval-timeout feedback string derives from `_APPROVAL_WAIT_TIMEOUT`
  rather than the hardcoded "1 hour".
2026-05-18 19:33:15 -07:00
renovate[bot] ded999f12b chore(deps): lock file maintenance 2026-05-18 19:32:31 -07:00
github-actions[bot] 6998b442a9 chore: download vendored JS files 2026-05-17 06:35:24 -07:00
renovate[bot] 692669a1e1 chore(deps): update dependency katex to v0.16.47 2026-05-17 06:35:24 -07:00
renovate[bot] 3069ecfb5e chore(deps): lock file maintenance 2026-05-17 06:35:11 -07:00
Patrick Buckley 1879874f07 docs(storage): clarify LIKE_ESCAPE contract with .like(escape=...)
The previous comment described "\\" as "non-default", which is
backwards — "\\" is the SQL standard escape character.  The
actually-non-default part is SQLAlchemy's ``.like()`` itself: it
defaults to no escape character, so ``escape_like``'s output is only
interpreted correctly when callers pass ``escape=LIKE_ESCAPE``
explicitly.  Reword to put the caller-side requirement first.
2026-05-14 15:13:00 -07:00
Patrick Buckley 98d4be8ffe fix(watch): deliver terminal fires instead of dropping them silently
WatchRunner._poll_watch committed active=False to the row BEFORE
calling _dispatch_result for a terminal fire, and the dispatch closure
registered by ChatSession.set_watch_runner enqueued each reminder with
a valid_until=is_watch_active predicate that re-read the row at drain
time. Since the runner already flipped active to 0, the predicate
returned False for every dispatched fire and NudgeQueue.drain silently
dropped the entry — the model never saw a watch result. Then a
subsequent action=cancel call hit list_watches_for_ws (filters
active==1), the now-inactive row was invisible, and the cancel
returned 'Watch "X" not found.' regardless of whether the watch had
actually run.

Reorder _poll_watch to dispatch before the row write, drop the
valid_until predicate from the watch closure (its only effect was the
bug above), and add a _terminal_dispatched guard on the runner so a
transient storage failure between dispatch and row-write doesn't
re-fire the reminder on the next tick. Add WatchRunner.forget_terminal_dispatched
and call it from the cancel path so an out-of-band deactivate (next_poll='')
doesn't leak the watch_id from the runner's pending-retry set indefinitely.

Cancel-by-name now routes through a new find_watch_by_name storage
method that ignores the active filter and prefers active rows over
newer-inactive same-name siblings. The session.py cancel branch
distinguishes 'already completed (auto-cancelled)' from 'not found'
so the model can tell apart 'this watch ran and finished' from
'no such watch.' Consolidate the two byte-identical _escape_like
/ _escape_ilike helpers in the storage backends into a single
turnstone.core.storage._utils.escape_like and apply it to the new
find_watch_by_name LIKE pattern so a model-supplied watch name
containing % or _ can't redirect a cancel to a sibling watch.

NudgeQueue.drain previously dropped predicate-failed entries without
logging anything, which is what hid this bug for so long. Drain now
emits nudge_queue.predicate_dropped: info for reason=predicate_false
(the normal lifecycle case — idle_children when every active child
finished between enqueue and drain), warning with exc_info for
reason=predicate_raised (a misbehaving predicate).

Tests: new test_poll_watch_terminal_fire_survives_drain (parametrized
stop_on_fired + max_polls_reached) drives the real WatchRunner._poll_watch
against a real tmp_db row and confirmed to fail against pristine main.
test_poll_watch_retry_deactivate_after_update_watch_failure exercises
the _terminal_dispatched retry-deactivate branch end to end.
test_cancel_clears_pending_terminal_dispatched_entry covers the cancel-
path leak case. test_find_by_name_prefers_active_over_newer_inactive
catches the ordering regression. test_find_by_name_treats_percent_as_literal
+ test_find_by_name_treats_underscore_as_literal pin the LIKE escape.
2026-05-14 15:13:00 -07:00
Patrick Buckley a8eec0d740 fix(vendor): widen update-vendored-js sweep to catch shared_static/ + .py
The shared_static exclude in scripts/update-vendored-js.sh was meant to
skip self-references inside vendored libraries, but it also hid
shared_static/renderer.js — which loads the vendored libs and pinned
mermaid-11.14.0 across every renovate bump since #426. Tests under
tests/test_web_helpers.py were similarly invisible because the include
list omitted *.py.

Replace the broad shared_static exclude with the specific old-versioned
vendor directory (about to be rm -rf'd next anyway), and add *.py to the
include list. Bump renderer.js to mermaid-11.15.0 to repair the live
404, and refresh the test fixtures to current vendor versions so they
stop drifting.
2026-05-13 16:12:03 -07:00
renovate[bot] 94f6721bda chore(deps): lock file maintenance 2026-05-13 15:47:31 -07:00
github-actions[bot] f28a3533a2 chore: download vendored JS files 2026-05-13 15:47:12 -07:00
renovate[bot] 1b80a29dcb chore(deps): update vendored js 2026-05-13 15:47:12 -07:00
renovate[bot] f652b3ff7a chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.14 2026-05-13 15:45:39 -07:00
renovate[bot] 658ffb1ba0 chore(deps): update dependency vitest to v4.1.6 2026-05-13 15:45:22 -07:00
Patrick Buckley 019138c411 docs(changelog): release 1.5.15 notes
Fixes the 1.5.14 admin.js syntax error that left the console UI
non-functional whenever an MCP server row had consented users.
2026-05-12 21:21:05 -07:00
Patrick Buckley 977153e981 fix(mcp): admin.js syntax error in bulk-revoke button (Phase 9)
Line 3446 used double-quote string delimiters with an embedded ">
that terminated the string mid-attribute, leaving "bulk-revoke (" as
bare tokens.  The rest of the surrounding block uses single-quote
delimiters; switch the broken line to match so the embedded > and "
sit safely inside the string.

The parse error wiped out every global in admin.js, so showAdmin and
the rest of the admin entry points were undefined — the console was
non-functional whenever an MCP server row had consented_users_count > 0.
2026-05-12 21:20:35 -07:00
Patrick Buckley f72033bff5 docs(changelog): release 1.5.14 notes
Backports OAuth-MCP Phase 9 (#516) to the stable/1.5 track. Introduces
forward-only migrations 054_mcp_pending_consent and
055_mcp_user_tokens_server_index.
2026-05-12 21:16:52 -07:00
Patrick Buckley adeb10bc2c feat(mcp): admin status, deferred-consent persistence, operator docs (Phase 9) (#516)
* feat(mcp): admin status, deferred-consent persistence, operator docs (Phase 9)

Completes the OAuth-MCP build-out (Phases 0-8 shipped) by closing the
operator + deferred-consent gaps:

1. **Per-(user, server) deferred-consent persistence** — when a
   non-interactive run (scheduled / channel) hits ``mcp_consent_required``
   or ``mcp_insufficient_scope``, the sync pool dispatchers now upsert a
   row into a new ``mcp_pending_consent`` table.  The dashboard hydrates
   the gear-icon badge from this table on load, so users who weren't
   online to see the in-flight SSE prompt still surface the deferred
   work on next login.  Cleared automatically by the OAuth callback
   handler on consent completion; manual user dismiss via new DELETE
   endpoints.  Composite PK ``(user_id, server_name)`` collapses repeat
   occurrences for the same server — no NULLs-not-distinct trap.

2. **Admin status pill + bulk-revoke** — the MCP Servers admin row now
   shows ``consented_users_count`` for ``auth_type=oauth_user`` rows
   when ≥1, with a two-step-confirm ``bulk-revoke`` button that drops
   every user's token for the server via the existing
   ``delete_mcp_oauth_rows_by_server_name`` primitive.  Upstream RFC
   7009 revoke is intentionally NOT attempted in bulk (avoids N
   upstream HTTP calls per admin click); audit detail records
   ``upstream_revoke_outcome=bulk_admin_no_upstream``.  A "last
   refresh" pill (age + outcome) renders on each row, sourced from a
   new ``_last_refresh`` dict populated by ``_refresh_server`` on every
   call (both manual ``refresh_sync`` and the ``_cb_auto_reconnect``
   follow-up).

3. **ClientType.SCHEDULED** added to the prompts module + scheduler
   passes it through to ``create_workstream``.  ``ChatSession`` now
   computes ``_is_interactive_for_consent`` at construction (WEB / CLI
   are interactive; CHAT / SCHEDULED are not) and plumbs the flag
   through ``call_tool_sync`` / ``read_resource_sync`` /
   ``get_prompt_sync`` to the three sync dispatchers.  The wrap at the
   ``_is_structured_error`` gate routes consent codes to the new
   ``_record_pending_consent_best_effort`` helper for non-interactive
   callers only; interactive sessions stay on the in-flight SSE path
   Phase 8 ships unchanged.

4. **Operator docs** — ``docs/mcp-oauth.md`` (operator guide, parallel
   to ``docs/oidc.md``: ``auth_type`` choice, OAuth client setup,
   encryption-key rotation, troubleshooting matrix) and
   ``docs/operations/mcp-oauth-headless.md`` (one-paragraph runbook
   per ``feedback_runbook_trust_llm.md``: pre-consent recipe for
   scheduled / channel-driven runs).

Schema
- Migration 054_mcp_pending_consent.py — composite PK
  ``(user_id, server_name)``, ``occurrence_count`` + ``first_seen_at`` /
  ``last_seen_at`` for recency metadata, ``idx_mcp_pending_consent_user``
  for the badge-load query.  No FKs (matches the rest of the
  oauth_user schema).
- Migration 055_mcp_user_tokens_server_index.py — adds
  ``idx_mcp_user_tokens_server`` on ``(server_name, expires_at)`` so
  the admin pill's ``count_mcp_consented_users_*`` queries don't
  full-scan against the leading-``user_id`` composite PK.
- Cross-backend: works on SQLite + PostgreSQL via dialect-specific
  ``on_conflict_do_update`` (PG ``postgresql.insert`` / SQLite
  ``sqlalchemy.dialects.sqlite.insert``).  No ``NULLS NOT DISTINCT``
  needed — the simplified PK eliminates the cross-version trap.

Endpoints
- ``GET /v1/api/mcp/oauth/pending`` — list deferred-consent records for
  the authenticated user.  Install-level gate via cached
  ``any_oauth_user_mcp_servers`` short-circuits to ``{pending: 0}`` on
  installs with no oauth_user MCP servers — local-auth deployments
  exercise zero new storage queries on this path.  The gate result is
  cached on ``app.state`` with a 60s TTL to spare repeat dashboard
  loads.
- ``DELETE /v1/api/mcp/oauth/pending/{server_name}`` — single dismiss.
  Returns 204 in both existed-and-deleted and never-existed cases
  (no cross-tenant existence leak); audits
  ``mcp_server.oauth.pending_consent_dismissed`` with
  ``mode=single`` + ``cleared=0|1`` so a session-hijack attacker
  scrubbing breadcrumbs leaves an audit trail.
- ``DELETE /v1/api/mcp/oauth/pending`` — bulk dismiss; audits
  ``mode=bulk`` + ``cleared=N``.
- ``POST /v1/api/admin/mcp-servers/{name}/bulk-revoke`` — admin
  bulk-revoke for the named server's per-user tokens.  Requires
  ``admin.mcp`` permission + 400s when the row isn't ``oauth_user``.

All four registered on both ``turnstone-server`` and
``turnstone-console`` (mirrors the Phase 8 ``/connections`` endpoint
shape).

Performance
- Admin list handler now uses a single ``GROUP BY`` bulk-count query
  (``count_mcp_consented_users_grouped_by_server``) wrapped in
  ``asyncio.to_thread`` rather than N per-row sync DB round-trips
  inside the async handler.  Skipped entirely when no row is
  oauth_user.

Frontend
- ``ui/static/app.js``: ``loadPendingConsents()`` hydrates the
  existing ``_pendingConsentServers`` set on dashboard init + after
  the user opens the settings modal.  Endpoint failures stay silent
  — the badge will be re-driven by the next in-flight tool error.
- ``console/static/admin.js``: ``consented_users_count`` pill +
  ``bulk-revoke`` button on each MCP row (only when ≥1 consented),
  two-step confirm matching the existing delete pattern.  ``last-
  refresh`` age + outcome pill in the per-row status cell, sourced
  from the freshest per-node entry in ``status[*].last_refresh_at`` /
  ``last_refresh_outcome``.  CSS for the pills in ``style.css``.

Tests
- ``test_mcp_pending_consent_storage`` — 13 tests covering upsert
  idempotency, list ordering, per-user isolation, single/bulk delete,
  count-by-server + grouped variant, install-level gate.
- ``test_mcp_pending_consent_dispatch`` — 9 tests, including the
  boundary-cross gate per ``feedback_tests_through_boundaries.md``:
  drives the real ``call_tool_sync`` → ``_dispatch_pool_sync`` →
  ``_is_structured_error`` → ``_record_pending_consent_best_effort``
  with a mocked classified-lookup so the structural plumb-through is
  verified end-to-end.  Includes a storage-failure test that pins
  the docstring's "envelope unchanged on storage failure" promise.
- ``test_mcp_pending_consent_endpoints`` — 11 tests: install gate,
  list-for-self, no-cross-user-leak, single/bulk delete, idempotent
  not-found, audit emission on single + bulk + cross-tenant dismiss.
- ``test_chat_session_interactivity_flag`` — 7 tests pinning the
  ``ClientType`` → ``_is_interactive_for_consent`` mapping against
  the module-level ``INTERACTIVE_CONSENT_CLIENT_TYPES`` frozenset.
- ``test_mcp_admin_bulk_revoke`` — 7 tests covering admin.mcp
  permission gate, 404 on missing, 400 on non-oauth_user, 200 with
  ``rows_deleted`` + ``consented_users_before``, audit row with
  ``upstream_revoke_outcome=bulk_admin_no_upstream``, cross-server
  isolation.
- ``test_mcp_oauth_handlers`` — 2 new callback tests pin the post-
  callback ``delete_mcp_pending_consent`` invocation: success-clears
  + storage-failure-still-redirects.
- 636 tests pass on the impacted surface (47 new + Phase 0-8 OAuth-MCP
  + session + prompts + storage admin).  ruff + mypy clean.

Hard invariants honored
- Static path byte-identical for ``auth_type ∈ {none, static}`` — the
  flag flows only through the pool dispatchers, which only fire when
  the row resolves to ``oauth_user``.
- ``asyncio.timeout`` (not ``asyncio.wait_for``) preserved on every
  AS / SDK / pool-loop await — no new awaits added to the hot path.
- Install-level gate on the badge endpoint: cached
  ``any_oauth_user_mcp_servers`` returns False on a row-less
  deployment → endpoint short-circuits without touching the pending-
  consent table; 60s TTL bounds the staleness window after admin
  flips ``auth_type``.
- Operator-actionable codes (key-unknown, url-insecure, *_forbidden)
  explicitly filtered out of persistence — they're outside the
  user-facing consent badge scope.
- Best-effort write: the structured-error envelope returned to the
  agent is identical whether the persistence write succeeds or fails
  (storage exception is logged with type name only — no chained
  context that could carry an ``httpx.Request`` bearer header).
- No ``exc_info=True`` on any new path that can chain a bearer-bearing
  ``httpx.Request``.
- Defensive parsing: ``_parse_pending_consent_envelope`` mirrors
  ``_is_structured_error``'s ``isinstance(decoded, dict)`` guard plus
  filters scope tokens through ``is_valid_scope_token`` capped at
  ``MAX_INSUFFICIENT_SCOPE_REPORTED`` — defense-in-depth even though
  production callers already validate upstream.
- Audit events on every dismiss endpoint so a session-control attacker
  scrubbing dashboard breadcrumbs still leaves a trail.

Cross-backend
- Tested on SQLite via the conftest backend fixture.
- PostgreSQL path uses ``postgresql.insert(...).on_conflict_do_update``
  parallel to the existing ``mcp_user_tokens`` upsert in Phase 3.

Deferred (not Phase 9 blockers)
- Multi-node pool eviction on bulk-revoke: only local-node sessions
  would be evicted if we built it, and there's no bulk-by-server
  primitive on MCPClientManager today; remote nodes will surface as
  a 401 on next dispatch which refreshes through the (now empty)
  token row.
- RFC 8693 / Azure OBO ``auth_type=oauth_token_exchange`` — captured
  in the design doc as a future architectural direction (~600 LOC +
  IdP-side admin work); requires OIDC token capture and per-MCP-server
  resource-trust configuration that v1 does not ship.

* docs(mcp): address Copilot review feedback on Phase 9

- Fix misleading admin.js comment that claimed the refresh pill rendered
  "<short-relative> <outcome>" — the pill actually renders only the short
  age, with outcome reflected via CSS class and tooltip.
- Replace broken feedback_secrets_not_in_env.md repo-root link in
  mcp-oauth.md with the inlined rationale (env-borne secrets reachable
  via shell tools / os.environ; TOML secrets are not).
2026-05-12 13:15:09 -07:00
Patrick Buckley 86944cb55d docs(changelog): release 1.5.13 notes
Adds notes for the 14 patches cherry-picked to stable/1.5 since 1.5.12:
reactive PG LISTEN/NOTIFY node discovery + event-driven wait_for_workstream,
memory tool audit trail, task_agent skill personas, plus fixes for the
LLM-visible default alias bypass, mermaid streaming parse errors,
proxy-prefixed re-auth, dashboard appbar visibility, and the PG test
backend on the notify dispatcher suite.

Introduces forward-only migration 053_services_notify_trigger.
2026-05-11 21:23:49 -07:00
Patrick Buckley b4299f8888 fix(task_agent): address Copilot feedback on skill parameter
- Put ``skill`` back in the access-denial list in the tool
  description with a clarification — TASK_AGENT_TOOLS does not
  include the skill tool, so sub-agents cannot switch personas
  mid-task.  Removing the disclaimer entirely created an ambiguity
  the LLM could misread.

- Minimize the skill_data carried on the approval item dict to
  ``name`` / ``content`` / ``risk_level`` only.  ``get_skill_by_name``
  returns the full ~30-column prompt_templates row including
  ``scan_report``, ``installed_by``, ``source_url`` — none of those
  flow through ``_exec_task`` / ``_evaluate_intent``, and they
  shouldn't ride along any future audit serializer that reads the
  approval item shape.

- Regression test for ``skill=""``, whitespace-only, and ``\t\n``
  values — pins the documented "empty value is acceptable" contract
  at the ``(args.get("skill") or "").strip()`` chokepoint.
2026-05-11 20:43:38 -07:00
Patrick Buckley 7d58df0d22 feat(task_agent): add optional skill parameter for per-call personas
The task_agent tool now accepts an optional ``skill=<name>`` argument
that loads the named skill's content as the sub-agent's persona,
substituting the hardcoded "# Task Agent" identity statement.  The
operating-guidance numbered list (one-shot, tool-use over narration,
no follow-up questions) is layered on top of every persona and always
applies — those are sub-agent semantics that a persona should ride on
top of, not replace.

Validation lives in ``_prepare_task`` so the approval surface tells
the operator what they're consenting to: the validated skill dict
(including content) rides on the item dict from prepare to exec to
defeat TOCTOU between consent and execution.  An unknown skill
returns a clean error item with a hint pointing at
``skill(action='search')``; a disabled skill returns a distinct error
so the LLM's recovery path can tell "not found" from "quarantined",
mirroring the enabled gate that ``_exec_skill(action='load')`` and
skill-search already apply.

High and critical skills now surface their risk tier on the approval
header (``, risk: critical``) and emit a
``task_agent.high_risk_skill`` warning — same signal ``_load_skills``
emits for session-level skills, so the operator sees the same flag
whether the skill is loaded session-wide or per-call.  ``_exec_task``
emits a ``task_agent.skill_invoked`` info log on the skill branch for
forensic traceability — the approval row captures the choice at
consent time, this log captures it at exec time so post-incident
search doesn't have to cross-walk approval and exec tables.

The ``_evaluate_intent`` func_args projection now includes the skill
name — without it, heuristic ``arg_pattern`` rules targeting a risky
persona name on ``task_agent`` silently no-op and the audit row loses
the choice.  Mirrors the long-standing ``spawn_workstream``
projection.
2026-05-11 20:43:38 -07:00
Patrick Buckley 2053becfbc fix(ui): attach settings menu keydown synchronously
Caught by Copilot on PR #514.  openSettingsMenu sets _settingsMenu
synchronously, but the menu's keydown handler was registered inside
setTimeout(0).  The previous-commit guard in the global keydown
handler returns early when _settingsMenu is set (so dashboard isn't
hidden by Escape over the menu), which created a window where
Escape had no handler at all — the global skipped, the menu's own
listener wasn't ready yet, and the menu got stuck open until the
next interaction.

Attach keydown synchronously; keep mousedown + initial focus in
setTimeout (mousedown to avoid the opening click triggering its own
outside-click close, focus because the menu DOM needs a tick to
settle layout).
2026-05-11 18:36:55 -07:00
Patrick Buckley 292a2800fc fix(ui): keep appbar visible on dashboard, gear-icon dropdown menu
Two related changes that surfaced when the user pointed out the proxy's
node-picker pill was unreachable from the proxied dashboard view: the
dashboard overlay was covering the entire appbar.

  - Dashboard overlay now starts at top: 48px so the appbar (with the
    proxy-injected node picker) stays visible and interactive while the
    dashboard is open.  showDashboard no longer marks ui-header inert
    (tab-bar and split-root still are).  The dashboard's role downgrades
    from dialog+aria-modal to region — the appbar being reachable above
    it would otherwise contradict aria-modal's "ignore everything else"
    semantics.

  - Gear icon converts from a direct openSettingsPanel() click into a
    dropdown menu with two items: "MCP connections" (existing modal) and
    "Logout".  Reuses the .ws-tab-dropdown shell for visual consistency
    with the workstream tab chevron menu and the proxy node-picker.
    Logout uses .destructive styling to reduce misclick risk.

Bug fixes caught by the merged code-review pipeline:

  - Global Escape handler skips when _settingsMenu is open, otherwise it
    fires hideDashboard() before the menu's own handler — wiping the
    composer text + staged attachments out from under the user.
  - Menu-item click refocuses the trigger before close, so
    openSettingsPanel captures the gear (not <body>) as the eventual
    return-focus target.
  - ArrowUp keyboard cycling uses idx <= 0 ? len - 1 : idx - 1 instead
    of (idx - 1 + len) % len so the no-focus case wraps to the last
    item rather than the second-to-last.  Same fix backported to
    showTabDropdown which had the identical modulo bug.
  - Position clamps reordered: right-edge override now runs before the
    left-edge floor so a menu wider than the viewport still clamps to
    mx >= 4 instead of going negative.
  - openSettingsMenu caches _settingsMenuTrigger so closeSettingsMenu
    can reset ARIA without re-querying the gear by id.
  - aria-controls lifecycle wired both ways (set on open, removed on
    close).
2026-05-11 18:36:55 -07:00
Patrick Buckley ee163c0ae4 fix(session): prevent LLM bypass of per-role plan/task model overrides
The LLM was passing ``task_agent(model="default")`` (and the same for
plan_agent) and routing to whichever backend the auto-created
``default`` alias was attached to at boot — flatspark in the verified
case (ws_id 7dde674) — silently bypassing the operator-configured
``model.task_alias`` / ``model.plan_alias`` (gh200).

Root fix:

- ``load_model_registry`` only synthesises the back-compat ``default``
  alias when neither DB nor ``[models.*]`` populate the registry.  The
  shim was only ever meant for single-CLI-model setups; with a multi-
  model DB it became a phantom routing target aliasing ``LLM_BASE_URL``.
- ``_render_agent_tool_descriptions`` filters ``default`` out of the
  LLM-visible alias list.  The English reading of "default" trips the
  model into picking it explicitly even when the description tells it
  to omit ``model=`` for the per-role default.

Defense-in-depth at the validator chokepoint
(``_validate_agent_model_override``): explicit rejection of
``alias == "default"`` (post-strip) with corrective guidance;
``default`` filtered out of the unknown-alias retry list so an LLM
probing with a bogus alias can't enumerate it back; the no-alternatives
wording is distinguished from the no-registry-configured wording.  The
render path also always rewrites tool descriptions instead of returning
early on filter-empty, so a reload that drops the registry to only
``default`` clears stale alias names left over from a prior render.
2026-05-11 16:53:50 -07:00
Patrick Buckley 6bdc6cf0bd feat(audit): emit memory tool save/update/delete events
Previously only the admin-console DELETE route emitted memory.delete
audit rows, so a long-running session whose memory was deleted via
the admin UI had no log trail showing what happened — masking
out-of-band deletes as apparent tool bugs.

The save branch now stamps memory.save (new row) or memory.update
(upsert); the delete branch does a lookup-then-delete-by-id pair so
the audit can record the resolved memory_id and type. All emissions
are best-effort: failures log at debug and swallow so an audit hiccup
never breaks the tool call itself. Reads (get/search/list) remain
un-audited.
2026-05-11 16:42:44 -07:00
Patrick Buckley 07ac0a4e7d fix(console): make proxy_api auth dispatch single-sourced
Copilot review on #511 flagged that the dispatch chain and the tests
both claimed to be in lockstep with one another, but only the comment
text said so — the parametrize list and the if/elif chain were two
independent hand-maintained copies, and the comments still referenced
the (long-reverted) ``_PROXY_AUTH_LOCAL_HANDLERS`` symbol.

Make the lockstep guarantee real by collapsing both copies onto one
``_PROXY_AUTH_LOCAL_HANDLERS: dict[tuple[str, str], str]`` mapping
``(method, path)`` to handler-name strings.  ``proxy_api`` resolves
the name through ``globals()`` at call time so ``patch(...)`` in
tests still observes the override — a dict of function refs would
have captured the originals at module load (which is why the first
attempt at this dispatch broke the tests and got reverted).  Test
cases now derive directly from ``_PROXY_AUTH_LOCAL_HANDLERS.items()``,
so adding or removing an entry in the dispatch table flows through
to the parametrize list automatically and the two can't drift.
2026-05-11 16:42:07 -07:00
Patrick Buckley 72839e82af fix(console): allow re-auth from inside the proxy-prefixed UI
When the user is on a proxied node page (``/node/{id}/...``) and the
JWT expires, the in-page login modal POSTs to ``/v1/api/auth/login``
which the proxy shim rewrites to ``/node/{id}/v1/api/auth/login``.
Two latent bugs both had to be fixed for the user to be able to
re-authenticate from inside the proxied UI:

1. ``is_public_path`` didn't recognise the ``/node/{id}/`` prefix
   over a public path, so the console's ``AuthMiddleware`` 401'd the
   login POST before any handler ran.  Extended via the existing
   ``_extract_proxied_path`` helper so a proxied public path stays
   public.

2. Even if the path had been public, ``proxy_api`` would have
   forwarded the request to the upstream node.  The upstream mints
   ``JWT_AUD_SERVER`` tokens; the console's ``AuthMiddleware``
   (expecting ``JWT_AUD_CONSOLE``) would reject those on the next
   proxied call, and ``_proxy_post`` drops ``Set-Cookie`` when
   forwarding anyway.  ``proxy_api`` now dispatches every entry in
   ``_PROXY_AUTH_LOCAL_PATHS`` (login, logout, setup, refresh,
   status, whoami, oidc/authorize, oidc/callback) to the console's
   own auth handlers, and short-circuits non-canonical methods on
   those paths with 405 instead of letting them slip through with
   the service-token fallback.

Tests parametrize across all eight local-dispatch entries so a future
refactor that drops a branch (or routes it through ``_proxy_post``)
fails loudly, plus a no-auth-header reproduction for the original
lockout and a 405 regression guard for the method-mismatch surface.
2026-05-11 16:42:07 -07:00
Patrick Buckley f8f076cf20 fix(renderer): mermaid streaming parser errors + progressive hljs (#510)
* fix(renderer): mermaid streaming parser errors + progressive hljs

Live streaming was rendering mermaid diagrams with `Parse error,
got 'PS'` messages — bare `(`, `[`, `{` inside unquoted edge / node
labels re-entered Mermaid's shape parser. Two unrelated streaming-
specific issues in the renderer pile-up here; this commit addresses
both plus a follow-on UX improvement for code highlighting.

## Mermaid label autoquoter

`_normalizeMermaidSource` wraps two label forms that Mermaid rejects
when they contain bare shape-delimiter chars:

  1. Edge labels:  `|content|`  →  `|"content"|`
  2. Rectangle node labels:  `ID[content]`  →  `ID["content"]`

Shapes whose syntax already nests delimiters — cylinders `[(...)`,
subroutines `[[...]]`, trapezoids `[/.../]` `[\...\]`, circles
`((...))`, hexagons `{{...}}`, diamonds `{...}` — are intentionally
left alone (their inner delimiters are part of the shape syntax;
quoting would corrupt them). Labels already wrapped in `"..."` are
also left alone. The rewrite is idempotent and runs before the
mermaid SVG cache lookup so identical malformed input hits the
cache on re-render rather than re-quoting per tick.

## Markdown fence-pair regex

The old fence regex `/(```+)([^\s`]*)\n([\s\S]*?)\1/g` would, mid-
stream, pair an unclosed ```mermaid open with the OPENING backticks
of a later ```python fence as the "close", handing mermaid a
truncated source. New regex:

    /(```+)([^\s`]*)\n((?:(?!\1)[\s\S])*?)\1[ \t]*(?=\n|$)/g

Two constraints close the gap:

  - `(?!\1)` inside the content quantifier blocks the lazy matcher
    from extending across another N-backtick run. Smaller inner
    counts (e.g. 3-backtick inner inside a 4-backtick outer) still
    pass since `\1` is the open's actual count.
  - `[ \t]*(?=\n|$)` after `\1` forces the close to a line
    boundary, so ```python (open with a language tag) can't
    masquerade as a previous fence's close.

Together: an unclosed fence stays as plain markdown until its true
close arrives, so neither mermaid nor hljs ever sees a mid-stream
truncated source.

## Progressive hljs

Extracted `postRenderHljs` from `postRenderMarkdown` with a source-
keyed `_hljsCache` (FIFO, cap 64, keyed on `language:source`) and
wired it into `_streamingRenderApply`. Closed code fences are now
syntax-highlighted as they stream in, matching the progressive
mermaid pattern from #426. Per-tick cost stays cheap because the
cache returns the pre-tokenized HTML synchronously on hit; only
unique (language, source) pairs pay `hljs.highlightElement`.

## Internal cleanup from the review pipeline

  - `_cacheFifoEntry(cache, key, value, max)` replaces the duplicated
    `_cacheHljsEntry` and `_cacheMermaidEntry`. Single tested
    implementation across four caches (hljs, mermaid svg, mermaid
    error, mermaid normalize memo). The "don't evict on overwrite"
    invariant is pinned per-cache in tests.
  - `_mermaidNormalizeCache` memoizes raw textContent → normalized
    output so the per-rAF-tick autoquoter split + regex doesn't
    repeat for unchanged diagrams. Eviction shares
    `_MERMAID_CACHE_MAX` with the SVG cache it feeds.

## Tests

The fake DOM in tests/test_renderer_js.py grew a few capabilities
to drive these paths:
  - `classList` is now array-like (length + indexed access) so the
    hljs language-extraction loop works.
  - `textContent` setter mirrors the real-DOM side effect of
    entity-escaping into innerHTML, so `escapeHtml()` round-trips
    (otherwise every `renderMarkdown` returns empty `<p>` tags).
  - `querySelectorAll` handles both `pre code.language-mermaid`
    and `pre code[class*='language-']`.

Added: 6 fence-pairing regression cases, 9 hljs-progressive cases
(cache hit / distinct sources / language separation / NO_HIGHLIGHT
langs / terminal class / eviction / overwrite / postRenderMarkdown
wraps hljs / _streamingRenderApply invokes hljs), 11 autoquoter
cases including both diagram sources from the live screenshot
encoded verbatim as parametrized regressions, and 3 normalize-memo
cases (populates on first call, consulted before normalize via
sentinel pre-seed, distinct sources cache separately).

Total: 104 renderer tests pass (was 67).

* fix(renderer): apply Copilot review feedback on #510

Two doc / harness adjustments from the PR review — no behavior
change in production code.

- The `_mermaidNormalizeCache` comment claimed eviction "stays in
  lockstep with the SVG cache". That was misleading: the two
  caches key on different things (raw textContent vs normalized
  source) and evict independently. Updated the comment to describe
  what they actually share (the cap, for memory footprint) and
  what they don't (positional coupling), and to note that the memo
  deliberately survives `_initMermaid` since normalize output is
  theme-independent.

- The fake DOM in tests/test_renderer_js.py had `innerHTML` setter
  clear `children` but leave `_textContent` intact, so subsequent
  `textContent` reads could return stale data after an innerHTML
  mutation (real DOM invalidates textContent on innerHTML write).
  No current test triggered this, but it would mask future bugs
  that depend on innerHTML/textContent consistency. Setter now
  clears `_textContent`; the children-derived fallback in the
  getter returns `''` after the wholesale replace.

All 104 renderer tests still pass; ruff + mypy clean.
2026-05-11 15:57:46 -07:00
renovate[bot] 8a847f5288 chore(deps): lock file maintenance 2026-05-11 09:06:18 -07:00
Patrick Buckley 42a87d0e1e fix(notify): unbreak PG test backend on the notify dispatcher suite
CI's postgres-backend run failed 11 of the new notify tests from #505.
Three independent issues:

1. Migration 053's ``services_notify`` trigger lives only in the
   alembic chain, but the test fixture in conftest.py calls
   ``init_storage(..., run_migrations=False)`` for speed.  That path
   skips migrations and relies on ``metadata.create_all`` for the
   table tree.  Previous alembic-only DDL (migrations 041 / 048
   ``CREATE INDEX CONCURRENTLY`` on workstreams) is performance-only,
   so tests never depended on it.  053's trigger is the first
   behaviorally-required alembic-only DDL in the project — without it
   ``register_service`` doesn't fire NOTIFY and the trigger-filter
   tests time out.

   Fix: declare the trigger function + trigger in ``_schema.py`` and
   attach them via ``sa.event.listen(services, "after_create", ...)``
   DDL events, gated on ``dialect == "postgresql"``.  The same SQL
   constants are imported by migration 053 so there's a single source
   of truth.  Test fixture stays unchanged — ``create_all`` now
   installs the trigger on fresh PG test DBs.  Migration covers the
   upgrade-on-existing-DB path; the two are mutually exclusive given
   ``create_tables = not run_migrations`` in ``init_storage``.

2. NotifyDispatcher tests fired ``storage.notify(...)`` immediately
   after ``d.start()`` and hit a race: the listener thread is
   concurrently calling ``psycopg.connect(listen_url)`` + ``LISTEN
   <channel>`` over the network, so the notify can land before any
   session is listening on the channel and PG drops it (pg_notify
   only routes to sessions LISTEN'ing at COMMIT time).

   Fix: dispatcher gains a ``_listener_ready: threading.Event`` set
   inside ``_listener_loop`` after each successful ``storage.listen``
   open and cleared on disconnect, plus a public
   ``wait_until_ready(timeout)`` method.  Tests use a new
   ``_start_ready(d)`` helper that calls ``start()`` + asserts ready.
   Production callers don't need this (real reactive traffic arrives
   well after startup), but it's the right primitive for any future
   "start dispatcher, immediately send" call site too.

3. ``TestSqliteNotify`` is misnamed — its tests run against whichever
   backend the ``storage`` fixture provides (PG by default in CI).
   Two of its assertions were SQLite-specific:
   ``assert got.pid == 0`` only holds for the synthetic in-process
   path (PG carries real backend PIDs), and
   ``test_synthetic_sweep_emits_after_interval`` is fundamentally
   SQLite-only (no sweep on the PG path).

   Fix: drop the pid assertion (channel + payload are the
   backend-agnostic invariants), add an ``_is_sqlite`` fixture mirror
   of ``_is_postgres``, and gate the sweep test on it.  The sweep
   test also moves from monkey-patching ``stream._sweep_interval`` to
   passing the ``sweep_interval`` kwarg that ``SQLiteBackend.listen``
   now accepts (from the earlier Copilot review fix).

Validated locally against a fresh ``turnstone_test`` PG DB: 263
storage + console + notify tests pass on PG, 257 on SQLite, mypy +
ruff clean.
2026-05-11 01:23:54 -07:00
Patrick Buckley 023606b968 feat(console): event-driven wait_for_workstream + idle cleanup via ChildEventBus
Retire two polling patterns in coord that have clean event sources.
PR 2 of 3 in the coord-completion stack; sits on top of PR #505
(reactive node discovery via PG LISTEN/NOTIFY).

`wait_for_workstream` (coord's block-wait tool) polled storage every
0.5 s in a worker thread regardless of whether anything had changed —
a 600 s wait incurred ~2400 round-trips. Now subscribes to a new
in-process `ChildEventBus` (`turnstone/core/child_event_bus.py`) and
blocks on `threading.Event.wait(min(remaining, WAIT_HEARTBEAT_INTERVAL))`:

- `CoordinatorAdapter` owns the bus; `_dispatch_child_event` calls
  `bus.notify(child_ws_id)` after each `_enqueue_on_ui` for the
  state-class branch (cluster_state, ws_closed, ws_rename,
  intent_verdict, approval_resolved, approve_request).
- Wait loop clears the Event BEFORE the storage snapshot to close
  the subscribe/check race; a notify between clear and the next
  `wait()` leaves the Event set so the loop re-reads without
  losing the wake-up.
- 2 s heartbeat cap preserves the existing `wait_progress` SSE
  cadence for the sidebar UI while cutting SSE traffic ~4x vs the
  pre-bus 500 ms cadence in the quiescent case.
- Worst-case completion latency is 2 s (vs pre-bus 0.5 s) because
  `set_state` buffers non-ERROR writes through `StateWriter`
  (async-flushed) while `emit_state` fans out immediately — a
  bus-driven wake can beat the flusher and read pre-transition
  state, then re-block until heartbeat. Deliberate trade-off; the
  SSE-traffic reduction outweighs the regression on the most
  common terminal transition.
- Defense-in-depth: ownership-filter `cleaned` to own-subtree
  before `register_waiter` so a foreign ws_id passed by an
  untrusted coord LLM (prompt injection) can't observe wake-up
  timing as a side channel. Predicate (`_row_in_own_subtree`)
  requires both `parent_ws_id == coord_ws_id` AND `user_id ==
  coord_user_id` parity — same gate strength as the existing
  `_is_own_subtree` mutating-op guard, so a corrupted /
  cross-tenant `parent_ws_id` alone can't satisfy it. Shared with
  `_snapshot_all` so the snapshot's `denied` shape stays in
  lockstep with the bus filter (Copilot review on #506).

Coord idle-cleanup thread polled the storage scan every
`check_every` seconds (~30 s on default 2 h timeout) even when no
coord was anywhere near idle. Now subscribes to
`SessionManager._state_subscribers` with a `tick_now` event and
blocks on `tick_now.wait(check_every)` — any state change wakes
the sweeper without waiting a full interval, AND the timeout still
fires the periodic sweep for the DB-orphan-only case. A
`min_sweep_interval=5 s` floor bounds DB-call traffic at ~0.2/s
under sustained activity so the loop can't tight-spin `close_idle`
at the rate of its own DB latency (6x improvement over the
pre-refactor fixed 30 s cadence under any activity, and prompt
state-change-driven wakes when below the floor).

`CoordinatorClient` constructor takes `child_event_bus` as a
required kwarg — there's no external SDK shape to preserve and
keeping it optional would silently mask a wiring bug in any future
caller. Tests construct their own `ChildEventBus()` per fixture.

Tests: 16 unit tests for `ChildEventBus` (register / unregister
symmetry, multi-waiter fan-out, multi-child waiter, subscribe/check
race, concurrent register / notify smoke); 7 new adapter tests
(bus notify fires for all 6 state-class events, drops for unknown
child / wrong ws_id); 7 new coord-client wait tests (subscribe-
after-terminal, notify wakes, unrelated notify doesn't wake,
heartbeat fires without notify, unregister on exit, multi-waiter
independence, cross-tenant denial via the user_id-parity filter);
8 idle-cleanup tests (initial sweep, heartbeat cadence, exception
swallowing, stop_event clean exit, state-change wake, subscriber
cleanup, mid-sweep wake, `min_sweep_interval` floor). All pass;
ruff + mypy clean. Full non-live suite: 6227 passed (+2 vs prior
baseline).
2026-05-11 01:06:47 -07:00
Patrick Buckley 752fea0fdd feat(console): reactive node discovery via PG LISTEN/NOTIFY dispatcher (#505)
* feat(console): reactive node discovery via PG LISTEN/NOTIFY dispatcher

Add a console-side `NotifyDispatcher` that holds a dedicated PostgreSQL
`LISTEN` connection and fans wake-ups out to per-channel handlers on a
separate dispatch thread. Cluster collector subscribes to a new
`services` channel and runs node discovery reactively — new-node /
graceful-deregister visibility drops from up-to-60 s to ~500 ms on
Postgres, with the 60 s discovery loop retained as the backstop for
crash-shaped node loss (NOTIFY only fires on real writes).

Storage layer gains a uniform `notify` / `listen` API:
- PostgreSQL: real `pg_notify` / `LISTEN` on a dedicated session-mode
  connection that bypasses pgbouncer (mandatory: pgbouncer is required
  in transaction-pool mode per docs, which is incompatible with LISTEN).
- SQLite: in-process fan-out + synthetic-sweep fallback so consumer
  code is identical across backends.

`TURNSTONE_DB_LISTEN_URL` (or `[database] listen_url` in config.toml)
points the dispatcher's connection direct-to-Postgres. Defaults to the
main DB URL when unset.

Migration 053 installs the `services_notify` trigger; it filters
heartbeat-only UPDATEs in-trigger so the 30 s × N-nodes heartbeat tick
stays quiet, while INSERT, DELETE, and url/metadata-changing UPDATE
still fire.

Dispatcher detail:
- Two threads: listener (drains stream → bounded queue) and dispatch
  (invokes handlers under exception suppression). Same-channel notifies
  coalesce per dispatch batch so an N-node deploy burst is one
  `_discover_nodes` per channel.
- Reconnect uses exponential backoff (1 s → 30 s cap). After any
  successful reopen — whether the prior failure was a stream-poll error
  or a connect / initial-LISTEN error — one synthetic Notify with
  payload="reconcile" is enqueued per channel so handlers re-read on
  the same code path they use for real events.

Future consumers (ConfigStore live reload, scheduler immediate
dispatch, audit live-tail) plug in by adding their channel to the
dispatcher's construction list.

Tests: 22 dispatcher tests (incl. reconnect + coalescing under stub
storage), 7 SQLite notify-stream tests, 4 PG-gated trigger-filter
tests, 4 collector wire-in tests. All pass; ruff + mypy clean.

* fix(notify): address Copilot review on #505

- _sqlite.py: SQLiteBackend.listen() now de-dupes channel names via
  dict.fromkeys before constructing the stream — duplicates would
  otherwise register the queue twice and double-deliver each notify.
- _sqlite.py: SQLiteBackend.listen() gains a keyword-only sweep_interval
  parameter (defaults to _SQLITE_NOTIFY_SWEEP_INTERVAL) — matches what
  the comment at the constant already promised, and lets future
  consumers without their own polling timer pick a tighter cadence
  without reaching into private stream attributes.
- _sqlite.py: documented the `except queue.Empty: pass` end-of-drain
  termination so it's not mistaken for swallowing an unexpected error.
- _postgresql.py: docstring referenced :func:`_pg_listen_url` which
  was renamed to _resolve_pg_listen_url during PR development.
- notify_dispatcher.py: module docstring referenced a non-existent
  _bootstrap_console_subsystem; wire-in is at console/server.py::main.

Refuted (no change, false positives from github-code-quality bot):
- 4× "Statement has no effect" on Protocol-method `...` ellipsis bodies
  (idiomatic Python Protocol declaration, not dead code).
- 2× "Mixed import style" in tests — `import ... as nd_mod` is
  intentional to allow attribute assignment for monkey-patching the
  module's `_RECONNECT_BACKOFF_INITIAL` constant inside try/finally.
2026-05-11 00:51:19 -07:00
renovate[bot] 81ba317a1d chore(deps): lock file maintenance 2026-05-11 00:50:37 -07:00
Patrick Buckley 6717a2b1f5 docs(changelog): catch up 1.5.0 through 1.5.12 release notes
Converts [Unreleased] to [1.5.0] and adds individual sections for
1.5.1 – 1.5.12. Covers: MCP OAuth 2.1 + PKCE (Phases 1–8), OIDC
hardening, metacog NudgeQueue + wake trigger, SSE refresh-resume,
reasoning persistence (Phases 1–4), structured watch-result cards,
skills unlock, inline child approvals, Stage 3 Children primitive
lift, coordinator composer parity, node capability auto-detection,
progressive mermaid rendering, and the full schema migration list
for each release.

Updates the track list to stable/1.4, stable/1.5, and main.
2026-05-10 22:33:27 -07:00
Patrick Buckley 562e98722f fix(server): always emit history event on /rewind to unblock edit-and-resend
Editing the first message in a workstream sends /rewind N where N is the
total user turns, leaving session.messages empty. The handler guarded the
history event with `if history:`, so only clear_ui was emitted. The
frontend dispatches the queued edit-and-resend from the history event
handler (app.js _pendingEditSend), so an empty history orphaned the
pending text and left the composer stuck in busy.

replayHistory already handles the empty case via showEmptyState(), so
emitting the event unconditionally is safe and unblocks the dispatch.
2026-05-10 16:45:43 -07:00
Patrick Buckley ce11f01a80 feat(session): enriched backend error messages with provider + URL
A bare ``httpx.ReadTimeout`` previously surfaced as ``ReadTimeout: timed
out`` — no provider, no base URL, no model — leaving the user with no
signal to tell whether a model server hung, the URL was wrong, or the
model isn't loaded on the backend.

``ChatSession._format_backend_error`` now rewrites known boundary
exceptions (httpx ``ReadTimeout`` / ``ConnectError`` / etc. and OpenAI /
Anthropic SDK ``APITimeoutError`` / ``APIConnectionError`` /
``NotFoundError`` / ``AuthenticationError`` / ``RateLimitError``) into
operator-actionable text that names the provider, base URL (query
string stripped before ``sanitize_error_text`` redacts credentials),
and model.  Matching is by class name so the helper carries no SDK
imports.  Unrecognised exceptions fall through to the legacy
``f"{type(exc).__name__}: {exc}"`` shape, preserving existing grep
targets.
2026-05-10 16:43:52 -07:00
Patrick Buckley 030bf2ead9 fix(session): AND-gate replay_reasoning_to_model with model capability
The Anthropic call sites in session.py passed the operator-side
`replay_reasoning_to_model` flag through without checking the
model's static `supports_reasoning_replay` capability. The OpenAI
Responses path AND-gated both flags in `_build_kwargs` so a model
without a reasoning lane (gpt-4o, etc.) silently skipped replay even
when the operator flag was set. The Anthropic path had no such gate.

For all current Claude entries this was a no-op asymmetry - every
`_ANTHROPIC_CAPABILITIES` row sets `supports_reasoning_replay=True`,
so `True AND op == op`. But:

- The capability flag was dead code on the Anthropic path
- A future Claude entry (or any Anthropic-shaped surface) shipping
  with the cap left at its False default would have replay fire
  anyway, against the cap declaration
- The asymmetry made `supports_reasoning_replay` an unreliable
  signal - readers couldn't tell if it gated anything per-provider

Move the AND-gate into `_resolve_replay_reasoning_to_model` via a
new optional `caps=` kwarg. When caps is provided, the resolver
returns `operator_on AND caps.supports_reasoning_replay`; when
omitted (back-compat for any caller not yet updated), it returns
the operator flag unchanged.

Thread caps through the three call sites: `_utility_completion`
(non-streaming), `_try_stream` (streaming, hoisted resolution out
of the retry loop since caps are attempt-invariant), and the
agent `_api_call` closure in `_run_agent`.

With the AND-gate now living at the session resolver, the redundant
in-provider gate in `OpenAIResponsesProvider._build_kwargs` is
removed. The provider now trusts the resolved bool it receives,
matching the AnthropicProvider shape and giving the cap a single
source of truth across providers. The two provider-level tests
that pinned the in-provider gate
(`test_include_omitted_when_capability_false`,
`test_include_omitted_by_default`) drop out; the session-level
boundary test
`TestSessionToOpenAIResponsesBoundaryIntegration::test_capability_false_omits_include_even_when_flag_true`
already covers the same end-to-end invariant.

Tests added:
- 4 resolver-level tests pinning the AND-gate semantics +
  back-compat when caps is omitted
- 1 wire-boundary integration test mirroring the OpenAI Responses
  `test_capability_false_omits_include_even_when_flag_true` -
  drives session._try_stream through the real AnthropicProvider
  with operator flag True + capability False and asserts the
  thinking block does NOT reach the SDK boundary

Existing `TestUtilityCompletionPassesFlag` test had its caps mock
upgraded from `SimpleNamespace` to a real `ModelCapabilities`
instance to satisfy the new attribute read and stay robust to
future capability fields.
2026-05-09 17:19:40 -07:00
Patrick Buckley aba446a748 fix(console): preserve cs=None fallback in /v1/api/models placeholder
Copilot review feedback on #500.  The original
``list_available_models`` had an implicit cs=None branch where the
placeholder still advertised ``registry.default`` (filtered against
enabled rows) when ``app.state.config_store`` was None but
``coord_registry`` was bound — useful in the rare degraded state
where lifespan wired the registry but the ConfigStore failed to
initialise.  The PR #500 refactor accidentally dropped that branch:
the helper requires a config_store, so the cs=None case fell out as
"blank coordinator default".

Add an explicit ``elif coord_registry is not None`` branch that
mirrors the helper's tier 3 with the placeholder's enabled-rows
filter applied.  New test exercises this path by passing
``config_store=False`` to the test fixture.
2026-05-09 17:06:47 -07:00
Patrick Buckley 77ea610ea2 fix(console): unify coordinator alias resolution across placeholder + factory
Previously /v1/api/models (home composer placeholder) and
console/session_factory.py walked separate two-/three-tier chains for
the coordinator alias.  session_factory was missing the
``model.default_alias`` tier, so admins who set the system default in
the Models tab would see it advertised but new coordinator sessions
would silently keep launching on ``registry.default``.

This commit:

- Extracts the chain into ``turnstone/console/coordinator_alias.py``.
  ``resolve_coordinator_alias`` returns the effective alias under a
  shared three-tier policy: explicit pin → ``model.default_alias`` →
  ``registry.default``.  Tier 2 is validated against
  ``registry.has_alias`` and falls through to tier 3 with a logged
  warning if unknown.  Tier 1 is intentionally passed through
  unvalidated so an explicit operator pin surfaces as 503 at
  ``registry.resolve`` rather than being silently swapped out.
- Wires both call sites through the helper.  The placeholder supplies
  an ``alias_filter`` that restricts every tier to enabled DB rows so
  the home composer never advertises a model the workstream picker
  can't actually offer; the session factory uses no filter (matches
  prior 503-on-typo behaviour for explicit pins).
- Adds direct integration tests for the session factory's chain
  (``tests/test_console_session_factory.py``) and updates the
  placeholder tests' fixture to provide a stub coord_registry, since
  the helper now requires one.
2026-05-09 17:06:47 -07:00
Patrick Buckley 3561616eaa docs(console-ui): clarify coordinator placeholder fallback comment
Light-review followup on 389400c8.

The "mirrors session_factory.py:109-110" claim was inaccurate —
session_factory's chain is two tiers (coordinator.model_alias →
registry.default) and skips model.default_alias entirely.  The
placeholder handler extends that chain with model.default_alias as
tier 2 so admins who set the default in the Models tab see it
advertised in the home composer.  Comment now lists the three tiers
explicitly and flags the session_factory-vs-placeholder drift case
(where model.default_alias ≠ registry.default) as a separate issue
to track.

Also lifts the ``from types import SimpleNamespace`` import in the
test fixture to module level — minor readability cleanup.
2026-05-09 15:38:16 -07:00
Patrick Buckley e3114045d2 fix(console-ui): align coordinator placeholder fallback with session_factory
Two Copilot-review followups on /v1/api/models default resolution.

- console/server.py: coordinator_default_alias now mirrors the full
  fallback chain in console/session_factory.py:109-110 — explicit
  coordinator.model_alias → model.default_alias → registry.default.
  The registry tier was missing, so the home composer placeholder went
  blank whenever an operator never set model.default_alias in the admin
  UI even though new coordinator sessions still launch on
  registry.default (loaded from config.toml [model].default by
  load_model_registry).  Two new tests cover the registry-default
  branch and the disabled-alias guard.
- console/static/app.js: _resolveModelLabel returns "" (not the bare
  alias) when the alias isn't found in the dropdown's model list, so
  callers can rely on the documented "fall back to neutral placeholder"
  contract.  Matches the existing doc comment.
2026-05-09 15:38:16 -07:00
Patrick Buckley 75d0b81b07 feat(console-ui): home composer placeholders, toggle component, admin polish
Bundles the click-around polish on the console admin UX.

Home composer + schedule modals
- /v1/api/models now exposes coordinator_default_alias + judge_default_alias,
  resolved through the same chain console/session_factory.py uses.  Both the
  home composer's MODEL / JUDGE MODEL placeholders and the schedule create /
  edit modal model placeholders rewrite to "Default — alias (model)" once
  the API responds.  The `models_changed` SSE refresh keeps placeholders
  current as operators edit per-role assignments.
- Composer.setOptionPlaceholder added so callers can update just the first
  option's text without disturbing the rest of the choice list.

Admin → Models → Roles
- Channel adapter row added (channels.default_model_alias) — the migration
  to the Roles sub-tab missed it.  Key added to
  _MODEL_AFFECTING_SETTING_KEYS so edits fire the SSE refresh, and to the
  settings-tab roleKeys skip-list so it only renders in one place.
- Plan/Task agent rows now display "(inherit)" instead of the misleading
  "(default — <alias>)" — those roles cascade through plan_model →
  agent_model → session model, not a single concrete default.
- coordinator.reasoning_effort accepts "" (inherit), matching
  model.plan_effort / model.task_effort.
- Blank options in each role's MODEL select now match the "alias (model)"
  shape used by the other rows.

Toggle-switch component
- New .toggle-switch component (visually-hidden native checkbox + styled
  track + label).  40×22 hit target meets WCAG 2.5.5 (AAA), inset ring on
  the off state for ≥1.5:1 contrast against the modal surface.
- .toggle-stack groups toggles in a column with .toggle-group-divider for
  conceptual grouping (used in the Add Model modal between "Active" and the
  paired Reasoning toggles).
- .toggle--flush modifier zeroes the default top margin for toggles that
  sit flush against a heading or a dynamically-rendered row.

Sweep — every admin-modal boolean checkbox is now a toggle:
schedule (cs/es-autoapprove, es-enabled), policy (ep/epp-enabled),
tool-mode (ctm/etm-default), skill (csk/esk-auto-approve, csk/esk-enabled),
MCP (mcp-auto-approve, mcp-enabled), Add Model (Active, surface-persisted-
reasoning, replay-reasoning), judge bool settings (cancel_on_approval et
al.), and the user-roles-modal role assignment list.  The two
ogp-cred / eogp-cred inline credential checkboxes stay as compact inline
boxes since they sit beside text inputs in tight horizontal rows.

Add Model modal — the "Enabled" toggle promoted to "Active" and moved to
the very top of the form.  Tooltip explains it gates dropdown visibility
without removing the definition.

MCP authorization — the three radio buttons replaced with a vertical
.segmented-control option list.  Selected row paints --accent-dim plus a
filled .segmented-indicator; focus ring uses --accent so it stays visible
on the currently-selected option.

Role permissions modal — the 19 permission checkboxes are now
.toggle-switch.perm-toggle (monospace lowercase identifiers preserved).
The permissions are split into Scopes / Admin / Workstreams & Tools
sections under caps-styled section headers so the row-flow grid no longer
slices `admin.*` mid-column.

Judge bool toggles use a static "Enabled" caption rather than flipping
text on `.checked`; flipping lagged 50–300 ms behind the slider position
because the caption was sourced from the post-save reload.

CSS cleanup — dead `.admin-checkbox` / `.perm-checkbox` rules removed.
Specificity audit (scripts/css_specificity_audit.py) returns no conflicts
on any new component class.

Tests — 525 pass on the affected slices; new tests/test_console_available_
models.py pins each branch of the resolution chain in /v1/api/models so the
home composer placeholder stays correct as precedence rules evolve.
2026-05-09 15:38:16 -07:00
Patrick Buckley bf888ed087 refactor(judge): require alias for judge.model, drop session-provider raw-model fallback
`IntentJudge.__init__` previously had a 3-way resolution chain: registered
alias → raw model id pinned onto the session provider → session model.  The
middle branch was a footgun documented in `console/session_factory.py:130-137`
— pinning the literal `judge.model` string onto the coordinator's session
provider silently broke every verdict whenever that provider didn't recognise
the model id (e.g. coordinator on Anthropic, `judge.model = "gpt-5-mini"` →
uniform `llm_fallback`).

Tightens to alias-only, matching `coordinator.model_alias` /
`model.plan_alias` / `model.task_alias`.  An unknown `config.model` now logs
a warning and inherits the session model — same path as empty.  Help text on
`judge.model` updated to clarify the contract.

Adds two regression tests in `TestModelAliasResolution` covering the
session-model inheritance for unknown values and the empty-model self-
consistency case.
2026-05-09 15:38:16 -07:00
Patrick Buckley 6f8574eef3 fix(reasoning): synthesize reasoning_text alongside non-reasoning provider_blocks
GoogleProvider attaches raw tool_call dicts as ``provider_blocks`` on
the finish chunk for ``thought_signature`` round-trip
(``_google.py:_iter_stream``).  When the same turn streamed Gemini's
``reasoning_content`` as ``reasoning_delta`` chunks, the prior
synthesizer bailed out the moment ``provider_blocks`` was non-empty
— so the captured reasoning was visible live but lost on page reload.

Replace the early-return-if-non-empty check with a reasoning-bearing
type test (``thinking`` / ``redacted_thinking`` / ``reasoning`` /
``reasoning_text``).  When none of those types appear, append the
synthetic ``reasoning_text`` block to the existing list rather than
replacing it — preserving Google's tool-call fidelity blocks.

Also addresses two doc-accuracy review findings:
- ``LLMProvider.extract_reasoning_text`` docstring no longer claims
  OpenAI Chat / Responses are unwired (Phase 3+4 shipped extractors).
- Add the method to the Protocol methods table in
  ``docs/architecture.md`` (was missing alongside the class diagram).
2026-05-09 02:45:13 -07:00
Patrick Buckley 321df65f64 fix(reasoning): per-block ANTHROPIC_VALID_BLOCK_TYPES filter + review fixes
The earlier all-or-nothing shape check on ``_provider_content`` discarded
every valid Anthropic block in a message the moment a single foreign
block (OpenAI ``reasoning``, Gemini thought parts, the synthetic
``reasoning_text`` from path-3 capture) appeared.  In the cross-model
resumption edge case that meant ``server_tool_use`` /
``web_search_tool_result`` blocks lost their ``encrypted_content``
silently, breaking web-search round-trip continuity on subsequent turns.

Replaced with a per-block walk: foreign blocks are dropped individually,
valid blocks ride the verbatim path, and an identity-preserving fast
path reuses the source list reference when nothing was filtered or
stripped (pinned by the ``is`` assertions in test_providers.py).

Also addresses validation-pass review findings:
- Document the single-tier vs three-tier ``surface_persisted_reasoning``
  resolution divergence between server.py:_build_history and
  session_routes.make_history_handler.
- Document why OpenAIResponsesProvider._convert_messages defaults
  ``replay_reasoning_to_model=False`` while Anthropic's defaults True.
- Document the ``source`` metadata field on synthetic ``reasoning_text``
  blocks as reserved-for-future-use, not dead code.
- Add edge tests for non-dict / missing-type-key blocks in
  _provider_content (defensive branches in the per-block walk).
2026-05-09 02:45:13 -07:00
Patrick Buckley 846af33571 test(reasoning): skip wire-boundary tests when anthropic extra missing
CI test job installs `[test]` extras, which omits `anthropic`. The two
TestSessionToWireBoundaryIntegration cases drive the real
AnthropicProvider.create_streaming, which calls _ensure_anthropic() and
raises ImportError. Match the repo convention (test_channel_discord,
test_channel_slack, test_tls_*) by gating the helper with
pytest.importorskip("anthropic").
2026-05-09 02:45:13 -07:00
Patrick Buckley 20e1e7b110 fix(reasoning): apply Copilot review feedback + docs sync
PR #498 round-robin review surfaced 5 findings.  4 applied; 1 rejected
with rationale.

Applied

* **Copilot finding 5** (history_decoration.py:341): dispatcher
  inspected only ``provider_content[0]['type']``.  OpenAI Responses
  captures EVERY ``output_item.done`` event into ``provider_blocks``
  (not just reasoning) — in practice the order is
  ``[reasoning, message, ...]`` but the API doesn't guarantee that;
  a hypothetical ``[message, reasoning]`` ordering would silently
  drop the reasoning under an index-only check.  Now walks the list
  for the first block whose type is in ``_BLOCK_TYPE_PROVIDER_FACTORY``,
  then dispatches the WHOLE list to that provider's extractor.  Each
  provider's extractor already filters internally by its own block
  type, so passing the full list is correct.  Regression test added
  (``test_dispatcher_scans_past_unrecognized_first_blocks``).

* **Copilot finding 3** (migration 052 docstring): the previous
  review-fix wave used sed to rename ``persist_reasoning`` →
  ``surface_persisted_reasoning`` everywhere, which mangled a
  historical reference in the migration docstring ("The earlier name
  ``surface_persisted_reasoning`` was renamed...").  Restored to
  point at the actual pre-rename name (``persist_reasoning``).

* **Copilot finding 4** (sdk/typescript/src/events.ts:26):
  ``HistoryEvent`` JSDoc still referenced ``persist_reasoning`` —
  the sed rename only walked ``turnstone/`` and ``tests/``, missing
  the TypeScript SDK.  Updated to ``surface_persisted_reasoning``.
  Also widened the comment to cover all three reasoning-bearing
  block types (Anthropic ``thinking``, OpenAI Responses ``reasoning``,
  synthetic ``reasoning_text``) instead of mentioning only Anthropic.

* **github-code-quality finding** (session.py:1120): ``_resolve_server_type``
  had a bare ``except Exception: pass``.  Replaced with a
  ``log.debug(..., exc_info=True)`` + explanatory comment.  Behaviour
  unchanged (still returns ``""`` on any lookup failure); failures
  are now observable under DEBUG triage.

Rejected (with rationale)

* **github-code-quality finding** (_protocol.py:265):
  ``extract_reasoning_text``'s body is ``...`` per ``LLMProvider``
  Protocol convention.  Every method in the file uses ``...`` (PEP
  544 idiomatic Protocol style).  Changing only this one to
  ``raise NotImplementedError`` would be inconsistent with the rest
  of the file.  CodeQL's "statement has no effect" warning is
  technically correct for ``...`` as a standalone expression but
  ignores the documented Python Protocol convention.  No fix.

Docs sync

* docs/api-reference.md: ``history`` SSE event message-shape table
  gains the optional ``reasoning`` field.
* docs/architecture.md: ``ModelCapabilities`` row in the type table
  gains ``supports_reasoning_replay``; ``StreamChunk`` and
  ``CompletionResult`` rows gain the existing ``provider_blocks``
  field (was missing pre-PR).  New "Per-model reasoning persistence"
  subsection under the Models config section, documenting the two
  flags + capability gate + three reasoning paths + cross-provider
  shape filter.
* docs/settings.md: new "Reasoning persistence (per-model)"
  subsection with the two-flag table and capability-gate note.
* docs/diagrams/03-core-engine-classes.puml: ``LLMProvider`` interface
  adds ``extract_reasoning_text`` + the new ``replay_reasoning_to_model``
  kwarg; ``ModelCapabilities`` class adds ``supports_reasoning_replay``.
  PNG regenerated.

Lint + test gate

* ruff check + ruff format clean.
* mypy clean (191 source files).
* pytest -m 'not live' — 6116 passed (3 deselected), +1 net new test
  (``test_dispatcher_scans_past_unrecognized_first_blocks``).
2026-05-09 02:45:13 -07:00
Patrick Buckley 33865ca9d2 fix(reasoning): apply full-stack review findings
Multi-stage /review on the full Phase 1+2+3+4 stack surfaced 9 findings
(0 critical, 3 major, 5 minor, 1 nit, 1 uncertain).  All applied.

Major

* perf-1 (session_routes.py:2402): make_history_handler ran sync
  storage.load_workstream_config inside async def history on the cold-
  workstream path, blocking the event loop on every dashboard /history
  request for non-resident workstreams.  Every other storage call in
  the same handler correctly used asyncio.to_thread.  Wrap the sync
  call in asyncio.to_thread (preserving the existing try/except so a
  DB failure still degrades to the conservative-default branch instead
  of bubbling out).

* q-2 (test_reasoning_audit_log_discipline.py): the security-sensitive
  test (reasoning text never lands at INFO+ severity) only covered the
  4 Phase 1 surfaces.  Phase 2 added the strip predicate in
  AnthropicProvider._convert_messages and Phase 3 added 3 more code
  paths that touch reasoning text — none guarded.  Added 4 parallel
  tests using the existing capture-and-walk infrastructure:
  OpenAIResponsesProvider.extract_reasoning_text,
  OpenAIChatCompletionsProvider.extract_reasoning_text,
  ChatSession._stream_response (drives the synth-block stamp via a
  fake reasoning-emitting stream), AnthropicProvider._convert_messages
  with replay_reasoning_to_model=False (drives the Phase 2 strip
  predicate).

* q-1 (model_registry.py:42): the persist_reasoning flag name implied
  storage-control but actually gates UI rehydration only — operators
  flipping it could reasonably expect "stop persisting reasoning" but
  storage of reasoning bytes happens in provider_data regardless.
  Renamed everywhere to surface_persisted_reasoning: ModelConfig
  field, migration 052 column (renaming in-place since 052 is not yet
  on main), schema, MODEL_DEFINITION_MUTABLE allowlist, _postgresql.py
  + _sqlite.py CRUD impls, _protocol.py create_model_definition
  signature, 3 console_schemas Pydantic models, console/server.py
  admin POST + PUT, model_registry row mapper, history_decoration.py
  helper parameter, server.py _build_history local var,
  session_routes.py make_history_handler local var, sdk/events.py
  HistoryEvent docstring, admin.js form id + override pill label,
  index.html form input id + UI label + tooltip, coordinator.js (none
  needed), and every test that referenced the old field name.  The
  admin tooltip now reads "Storage of reasoning bytes is unaffected
  by this flag — they ride in provider_data regardless" so the
  decoupling stays explicit at the operator surface.

Minor

* bug-1 (history_decoration.py:336): dispatcher discriminated on
  provider_content[0]["type"] only.  Anthropic's redacted_thinking
  blocks (sealed by the safety system) can appear before, after, or
  interleaved with regular thinking blocks per the API docs.  When a
  redacted block lands first, the dispatcher returned "" and the UI
  silently lost the surrounding thinking text.  Registered
  "redacted_thinking" as a second key in _BLOCK_TYPE_PROVIDER_FACTORY
  pointing at the same AnthropicProvider factory — the existing
  extractor's type=="thinking" filter already correctly skips redacted
  blocks while walking the full list.  Regression test added.

* q-3 (_protocol.py:155): replay_reasoning_to_model defaults split
  across 9 sites — operator-side defaults to False (matches DB
  server_default), provider-API defaults to True (back-compat with
  direct callers).  Original "pick False everywhere" fix would have
  silently flipped behaviour for any direct provider caller.  Instead
  documented the intentional bifurcation in the Protocol's
  create_streaming docstring.

* q-4+q-5 (_protocol.py:107 + 3 providers): MAX_REASONING_DISPLAY_BYTES
  was enforced via Python str slicing which counts code points, not
  UTF-8 bytes — 4-byte CJK/emoji glyphs would blow past the byte
  ceiling.  Renamed to MAX_REASONING_DISPLAY_CHARS to match actual
  behaviour.  Hoisted the 4-line truncation pattern into a shared
  _join_reasoning_with_cap helper in _protocol.py; each provider's
  extractor becomes a single line at the tail.

* q-6 (tests/_session_helpers.py): _NullUI + _make_session were
  duplicated verbatim between test_session_replay_reasoning.py and
  test_session_synth_reasoning_block.py.  Hoisted to a shared
  tests/_session_helpers.py module (importable, leading underscore so
  pytest doesn't try to collect it).  test_model_registry.py's
  _make_session has a different signature (registry/model_alias args
  + _FakeUI) and is not a candidate for sharing.

Nit

* q-7 (history_decoration.py:286): _make_provider_factory used a
  dict-as-cell workaround for closure read-only scope.  Replaced with
  the more idiomatic nonlocal pattern.

Lint + test gate

* ruff check + ruff format -- clean.
* mypy -- no issues across all 191 source files.
* pytest -m 'not live' -- 6115 passed (3 deselected).  Net +5 tests
  (4 audit-log discipline + 1 redacted_thinking dispatcher).

Refinements vs the dedupe output (caught during sanity rendering
the report)

* perf-1 fix preserved the try/except wrapper.  The original "wrap in
  to_thread" one-liner would have let an OperationalError bubble out
  instead of degrading to the fallback branch.

* q-3 fix explicitly documented the bifurcation rather than
  collapsing both sides to False.  "Pick False everywhere" would
  silently flip back-compat behaviour for direct provider callers.

* q-1 fix included the admin.js:5292 fallback site
  (m.persist_reasoning !== false) that the original threaded-change
  list missed.

* q-6 fix verified the third _make_session in test_model_registry.py
  is structurally different (different signature + different UI
  helper) and intentionally NOT a dedupe target.
2026-05-09 02:45:13 -07:00
Patrick Buckley a75b2f026b feat(reasoning): OpenAI Responses + Chat Completions capture/replay (Phase 3+4)
Wire reasoning capture and (where the API supports it) replay for the
two remaining provider paths.  Phase 3 was originally scoped as
"OpenAI Responses + Gemini" but a spike against the OpenAI SDK source
revealed that Gemini routes through the OpenAI-compatible endpoint
(``/v1beta/openai/``), which is structurally identical to vLLM /
llama.cpp / any other Chat-Completions-shaped local model.  Phase 3
and Phase 4 collapse into one feature with two distinct sub-paths:

* **Path 2 (OpenAI Responses)** — full capture+replay.  ``include=
  ["reasoning.encrypted_content"]`` on the request makes the API
  surface ``encrypted_content`` on reasoning items in
  ``provider_blocks``; ``_convert_messages`` round-trips them as
  ``ResponseReasoningItemParam`` input items on subsequent turns.
  Verified against the OpenAI Python SDK 2.33.0 source
  (``response_reasoning_item.py:31-62``,
  ``response_reasoning_item_param.py:33-37``,
  ``response_create_params.py:70-74``).  Even with ``store=False``,
  ``encrypted_content`` round-trips correctly per the SDK's own
  documentation.

* **Path 3 (Chat Completions / vLLM / llama.cpp / Gemini-compat)** —
  persist-only.  Canonical OpenAI Chat Completions has no reasoning
  field on the wire, but several local-model servers tack on
  ``delta.reasoning_content`` as Pydantic extras.  ``ChatSession.
  _maybe_synth_reasoning_block`` stamps a synthetic ``{type:
  "reasoning_text", text, source?}`` block onto ``_provider_content``
  at end-of-stream when no native ``provider_blocks`` were emitted but
  ``reasoning_parts`` accumulated text.  The ``source`` field carries
  ``server_compat.server_type`` (vllm, llama.cpp, sglang, …) for
  diagnostic value — informational only, doesn't gate behaviour.
  Reasoning text NEVER replays back to the model on this path; it
  rides ``_provider_content`` only for ``/history`` UI rehydration
  and gets stripped from the wire by the existing
  ``sanitize_messages`` underscore-prefix strip on every request.

What this change does

* ``ModelCapabilities.supports_reasoning_replay: bool = False`` added
  to the dataclass.  Set True on every OpenAI reasoning model
  (gpt-5* + o-series via the Responses API) and every Anthropic
  Claude entry (default + 6 model-specific).  Path-2 wire-build does
  ``replay_active = bool(replay_reasoning_to_model and caps.supports_
  reasoning_replay)`` so an operator who flips the flag on a
  non-reasoning model (gpt-4o via Responses) silently no-ops rather
  than emit a malformed ``include=`` request.

* ``OpenAIResponsesProvider`` gains:
  - ``_build_kwargs`` accepts ``replay_reasoning_to_model: bool``
    (threaded from ``create_streaming``/``create_completion``);
    adds ``include=["reasoning.encrypted_content"]`` when active.
  - ``_convert_messages`` accepts the same flag, captures
    ``_provider_content`` reasoning items pre-sanitization, and
    emits them as input items immediately before the assistant
    message they belong to.  Position is tracked by ASSISTANT
    ORDINAL (not raw index) — ``sanitize_messages`` drops orphan
    tool results and inserts synthesized error tool messages, but
    NEVER drops or duplicates assistant messages, so the n-th
    assistant in the original list is invariably the n-th in the
    sanitized list.  Index-based lookup would have silently
    misrouted reasoning attachments after any tool-message repair.
  - ``extract_reasoning_text`` walks ``type=="reasoning"`` items and
    returns ``summary[*].text`` + ``content[*].text`` concatenation.
  - ``_reasoning_item_for_input`` projects a stored item into
    ``ResponseReasoningItemParam`` shape (drops server-only
    ``status``).  Returns ``None`` when ``id`` is missing or non-
    string per the SDK ``Required[str]`` schema; caller skips
    appending, preventing malformed input items from reaching the API.

* ``OpenAIChatCompletionsProvider`` gains:
  - ``extract_reasoning_text`` walks synthetic
    ``type=="reasoning_text"`` blocks and returns the concatenated
    text directly (no underlying provider semantics — the synth
    block IS the surface).

* ``ChatSession`` gains:
  - ``_resolve_server_type(alias)`` reads ``server_compat.server_type``
    from the active model's capabilities dict.
  - ``_maybe_synth_reasoning_block(provider_blocks, reasoning_parts)``
    creates the synthetic ``reasoning_text`` block when no native
    blocks were emitted but reasoning was captured.  Wired at the
    end of ``_stream_response`` immediately before the
    ``_provider_content`` stamp.

* ``history_decoration.py`` dispatcher collapses three near-identical
  lazy-init singleton getters (one per recognised block type) into a
  single ``_BLOCK_TYPE_PROVIDER_FACTORY`` dict + helper.  Adding a
  fourth provider becomes a one-line dict entry.

* Constants hoist: ``MAX_REASONING_DISPLAY_BYTES = 64 * 1024`` moved
  from three sibling provider modules into ``_protocol.py`` so a
  tuning change propagates uniformly to every provider's display path.

Cross-provider safety

The synthetic ``reasoning_text`` block type is intentionally NOT in
``ANTHROPIC_VALID_BLOCK_TYPES`` (Phase 2 constant).  Cross-model
resumption (operator switches from a local model to Anthropic mid-
workstream) falls through Phase 2's shape filter cleanly to the
text+tool_calls rebuild path rather than reaching Anthropic with a
malformed block.  Pinned by ``test_synthetic_block_falls_through_
anthropic_shape_filter``.

Same protection applies in reverse: OpenAI Responses
``type=="reasoning"`` items reaching Anthropic mid-workstream fail
the shape filter and rebuild from text+tool_calls.

Tests (49 net new tests)

* ``tests/test_provider_openai_responses_reasoning.py`` (21 tests):
  - Extractor unit tests: empty/none/no-reasoning/single/mixed/
    truncation/malformed/non-list (8).
  - ``_reasoning_item_for_input`` projection (4 tests including the
    new None-on-missing-id guard).
  - ``_build_kwargs`` include= gating: flag+capability/flag-false/
    capability-false/default-omits (4).
  - ``_convert_messages`` reasoning round-trip: emit-before-assistant/
    drop-on-replay-false/foreign-shape-skipped/default-replay-false (5).

* ``tests/test_session_synth_reasoning_block.py`` (23 tests):
  - ``_maybe_synth_reasoning_block`` direct unit tests (6).
  - Cross-provider safety regression — synthetic block falls through
    Anthropic shape filter (2).
  - ``OpenAIChatCompletionsProvider.extract_reasoning_text`` for the
    new synthetic block type (6).
  - ``_resolve_server_type`` direct unit tests (5).
  - ``_stream_response`` integration tests driving fake reasoning-
    emitting streams through the actual session method (3 tests
    — added in response to a code-review finding that pinned the
    wire-up at session.py needs an integration test).

* ``tests/test_session_replay_reasoning.py`` extended with 4
  ``TestSessionToOpenAIResponsesBoundaryIntegration`` tests driving
  ``session._try_stream`` -> real ``OpenAIResponsesProvider`` ->
  captured ``client.responses.create`` SDK boundary call.  Negative-
  tested: temporarily reverting the ``include=`` step in
  ``_build_kwargs`` makes ``test_replay_true_adds_include_to_
  responses_request`` fail; restoring makes it pass.

* ``tests/test_history_decoration.py`` extended with the new
  ``reasoning_text`` dispatcher branch test, and the Phase 1 stub
  test for the OpenAI Responses dispatcher branch was tightened
  (it now asserts real text extraction instead of the empty-string
  stub).

* ``tests/test_provider_anthropic_reasoning.py`` had its Phase 1
  ``OpenAIResponses returns "" for reasoning blocks`` stub test
  retitled and updated to assert the real Phase 3 behaviour.

Code-review pass

Multi-stage ``/review`` pipeline (4 finders + verify + dedupe) ran
on this diff.  6 findings (1 major, 3 minor, 2 nit), 0 critical, 0
security, 0 performance.  All applied:

* MAJOR (bug-1+bug-4+q-1): ``_convert_messages`` enumerate-index
  lookup was unsound under ``sanitize_messages`` length changes.
  Fixed by switching to assistant-ordinal-keyed lookup.
* MINOR (q-2+q-3): ``_MAX_REASONING_DISPLAY_BYTES`` duplicated
  across three provider modules + declared after first use.
  Fixed by hoisting to ``_protocol.py``.
* MINOR (q-4): three near-identical singleton getters in dispatcher.
  Fixed by collapsing to ``_BLOCK_TYPE_PROVIDER_FACTORY`` dict.
* MINOR (q-5): ``_maybe_synth_reasoning_block`` wire-up not pinned
  by integration test.  Fixed by adding three
  ``TestStreamResponseSynthBlockIntegration`` tests.
* NIT (bug-2): ``_reasoning_item_for_input`` fell back to ``id=""``;
  fixed to return ``None`` on missing/non-string id.
* NIT (q-6): four naming variants for the same concept; renamed
  ``_convert_messages`` kwarg to match the operator-flag name.
* REFUTED (bug-3): SDK distinguishes summary vs content as separate
  fields; no double-counting concern.

Briefing departures

The briefing's Phase 3 plan grouped Gemini with OpenAI Responses on
the assumption that Gemini reasoning had its own native shape (like
Anthropic's ``thinking``).  The spike confirmed Gemini-via-OpenAI-
compat is path-3 (Chat Completions shape, no native reasoning
items).  Phase 3+4 merger handles Gemini for free via the synthetic
``reasoning_text`` block — same mechanism used for vLLM and
llama.cpp.  Whether Gemini's specific endpoint actually emits
``reasoning_content`` deltas is server-dependent and not yet
empirically verified; capture is best-effort (server-emission-driven,
no flag gate).

The briefing's Phase 4 plan stamped reasoning as Anthropic-shaped
``thinking`` blocks ``{type: "thinking", thinking: <text>}``.  This
PR uses a distinct ``{type: "reasoning_text", text, source?}`` shape
to avoid a cross-model resumption hazard the briefing missed: an
unsigned synthetic Anthropic-shape block reaching Anthropic's wire
would 400 the API.  The distinct shape falls through Phase 2's shape
filter cleanly without needing signature validation in the filter.

Lint + test gate

* ruff check + ruff format -- clean.
* mypy -- no issues across all 191 source files.
* pytest -m 'not live' -- 6110 passed (3 deselected).  Phase 3+4
  added 49 net new tests.
2026-05-09 02:45:13 -07:00
Patrick Buckley c8044111ec feat(reasoning): wire-build shape filter + replay flag (Phase 2)
Make ``replay_reasoning_to_model=False`` actually suppress prior-turn
thinking blocks on the Anthropic wire (Phase 1 stored the operator
flag but the wire path always re-sent ``_provider_content``
verbatim).  As a side benefit, close a pre-existing latent bug where
foreign-shaped ``_provider_content`` (e.g. an OpenAI Responses
``type="reasoning"`` block reaching Anthropic on a mid-workstream
model switch, post-Phase-3) would have 400'd the API.

Why now: Phase 1 shipped the operator knob and UI rehydration but
the wire payload still always carried thinking blocks for
Anthropic-with-thinking turns.  Operators flipping replay=False saw
no behaviour change on the actual API call -- the flag only affected
``/history`` rendering.  Phase 2 closes that gap.

What this change does

* ``ANTHROPIC_VALID_BLOCK_TYPES`` (frozenset of 8 block types
  Anthropic's input boundary accepts) and
  ``ANTHROPIC_REASONING_BLOCK_TYPES`` (the strip subset) added at
  the top of ``_anthropic.py``.  The strip set is intentionally
  narrow: ``{"thinking", "redacted_thinking"}`` -- ``tool_use`` /
  ``server_tool_use`` / ``web_search_tool_result`` (which carry
  web-search ``encrypted_content``) MUST survive for round-trip
  continuity, and a regression test pins this.
* ``_convert_messages`` signature gains
  ``replay_reasoning_to_model: bool = True`` (back-compat default
  -- production call sites pass the resolved value explicitly).
  The verbatim ``_provider_content`` replay path is now wrapped by
  a shape-validity check using ``ANTHROPIC_VALID_BLOCK_TYPES``;
  foreign-shaped payloads fall through to the existing text+
  tool_calls rebuild path rather than reaching the API.  When
  shape is valid AND replay=False, a list comprehension drops
  thinking blocks from ``wire_blocks`` while preserving
  tool_use / web_search blocks.  When all blocks are stripped
  (message had only thinking, no text or tool_calls), the message
  also falls through to the rebuild path -- which silently skips
  if both content and tool_calls are empty (correct: stripped
  reasoning has nothing to replay).
* Orphan-tool detection still walks the ORIGINAL ``provider_content``
  (not ``wire_blocks``) so the strip cannot accidentally lose the
  source-of-truth tool_use IDs.  The implementation comment pins
  this invariant.
* Protocol surface grows the kwarg on both ``create_streaming`` and
  ``create_completion``.  ``OpenAIChatCompletionsProvider``,
  ``OpenAIResponsesProvider``, and ``GoogleProvider`` (via
  inheritance) accept the kwarg and ignore it -- they have no
  first-class reasoning shape on the wire today.  Phase 3 will use
  it on the OpenAI Responses adapter to gate
  ``include=["reasoning.encrypted_content"]``.
* ``ChatSession._resolve_replay_reasoning_to_model(alias)`` reads
  ``ModelConfig.replay_reasoning_to_model`` from the registry,
  defaulting to ``False`` on lookup failure (the conservative
  miss-fallback: replaying reasoning text against an unknown
  operator preference is worse than missing the strip).  Threaded
  into the three production call sites:
  ``ChatSession._try_stream`` (streaming), ``_utility_completion``
  (title gen / compaction / extraction), and the agent provider
  call site (plan / task agents).

Token calibration deferred to Phase 4

The briefing's optional Phase 2 step (extending ``_msg_text_chars``
to count ``_provider_content`` bytes that survive the strip)
required either invasive flag-threading through every call site
of the static method or a lossy approximation that picked the wrong
direction for the default case.  Per the briefing's ``pick a
phase'' guidance, this is bumped to Phase 4.  The pre-existing
silent under-count on Anthropic-thinking turns persists when
replay=True.  Strip-when-False naturally fixes the under-count by
keeping the bytes off the wire entirely; the residual case is the
opt-in replay path.

Tests (28 new, all driving through real boundary objects)

* ``tests/test_provider_anthropic_replay.py`` (19 tests):
  - Strip vs preserve under both flag values (3 tests including
    redacted_thinking).
  - Default-kwarg back-compat preserves verbatim replay (1 test).
  - Web-search tool_use + server_tool_use + web_search_tool_result
    survive strip with encrypted_content intact (2 tests, edge 14).
  - Orphan-tool synthesis after strip -- pins the
    ``provider_content`` source-of-truth read at lines 397-433
    (1 test).
  - Foreign-shape fallthrough: OpenAI ``type="reasoning"`` block
    rebuilds via text+tool_calls (1 test).
  - Mixed-shape fallthrough: even one foreign block forces
    rebuild (1 test).
  - Empty / None / non-list ``_provider_content`` fallthrough
    (3 tests).
  - Legacy Anthropic-thinking row pre-Phase-2 stays in verbatim
    path -- no regression on existing conversations (2 tests).
  - All-blocks-stripped fallthrough behaviour: rebuild from text
    if available, silently skip if not (2 tests).
  - Constants pinning: strip set is narrow, valid set includes
    web search, strip is subset of valid (3 tests).
* ``tests/test_session_replay_reasoning.py`` (12 tests):
  - Resolver: 6 tests covering miss / default / set / explicit /
    fallback alias / exception.
  - Streaming call site: 3 tests pinning the kwarg propagates
    through ``_try_stream`` to a stub provider.
  - Non-streaming call site: 1 test pinning
    ``_utility_completion`` propagates the flag.
  - End-to-end boundary integration: 2 tests driving
    ``_try_stream`` -> real ``AnthropicProvider`` -> captured
    Anthropic SDK ``client.messages.stream`` boundary, asserting
    on the ACTUAL wire payload shape.  Negative-tested:
    temporarily reverting the kwarg-thread at
    ``_anthropic.py:create_streaming`` makes the wire test fail
    with ``Strip predicate did not fire at wire boundary``;
    restoring makes it pass.

The boundary integration tests were added in response to a code
review finding that the bare-stub call-site tests would not catch
a regression where the provider stops reading the kwarg or
``_convert_messages`` silently drops the strip.  The integration
tests close that gap by inspecting what reaches the (mocked) SDK,
not just what the provider was called with.

Lint + test gate

* ruff check + ruff format -- clean.
* mypy -- no issues across all 191 source files.
* pytest -m 'not live' -- 6061 passed (3 deselected).  Phase 2
  added 28 net new tests.
2026-05-09 02:45:13 -07:00
Patrick Buckley 1873e7a758 feat(reasoning): persist reasoning text on history payload (Phase 1)
Surface stored Anthropic thinking blocks on /history responses so
refreshing the page rehydrates the reasoning bubble. Wire payloads
unchanged. Per-model operator knobs added to model_definitions for
both UI rehydration and (Phase 2) wire-build replay.

Why now: reasoning is already round-tripped via _provider_content for
Anthropic-with-thinking turns, but never surfaces on the history wire,
so a tab reload showed only the final answer with no rationale.
Operators also have no per-model lever to opt out of UI display or to
opt in to replay-to-model on subsequent calls.

What this change does

* Migration 052 adds two boolean columns to model_definitions:
  persist_reasoning (default 1) controls UI rehydration; replay_
  reasoning_to_model (default 0) reserved for Phase 2's wire-build
  shape filter. Mirrors the enabled column pattern (NOT NULL +
  integer server_default).
* LLMProvider Protocol gains extract_reasoning_text(provider_blocks)
  with concrete impls on AnthropicProvider (walks type=='thinking'
  blocks, joins with newline, caps at 64 KiB) and no-op stubs on
  OpenAIChatCompletionsProvider + OpenAIResponsesProvider. Google
  inherits the no-op via OpenAIChat. Phase 3 will wire the OpenAI
  Responses extractor once include=['reasoning.encrypted_content']
  is requested.
* turnstone.core.history_decoration gains a structural dispatcher
  extract_reasoning_text_from_provider_content keyed off the first
  block's type field (Anthropic 'thinking' / OpenAI Responses
  'reasoning' / Gemini 'thought' are non-overlapping by API design).
  Both history surfaces use it: _build_history calls the dispatcher
  directly (the SSE-replay path builds entry dicts from scratch),
  and the lifted make_history_handler runs the list-helper variant
  in the existing to_thread block.
* make_history_handler resolves persist_reasoning via three tiers:
  live session -> workstream_config.model_alias (the same key
  SessionManager uses to rehydrate the original model after process
  restart) -> conservative True default. Operator flag-flip takes
  effect uniformly on both warm and cold workstreams.
* Frontend: app.js replayHistory and coordinator.js role==='assistant'
  branch each call the existing reasoning-bubble construction (for
  app.js, the document.createElement pattern from the live SSE
  handler; for coord, the appendMsg('reasoning') helper) when
  msg.reasoning is non-empty. Reasoning bubbles render before the
  content bubble, matching live SSE order.
* Admin UI: two checkboxes ('Persist reasoning', 'Replay reasoning
  to model') in the model edit modal, plus override-pill display in
  the model row when set to non-default values.

What is intentionally out of scope

* Phase 2 -- ANTHROPIC_VALID_BLOCK_TYPES shape filter at
  _anthropic.py:312-316, _convert_messages replay_reasoning_to_model
  parameter, thinking-strip branch, _msg_text_chars token-calibration
  extension. The replay flag is stored but not consumed on the wire.
* Phase 3 -- OpenAI Responses include=['reasoning.encrypted_content'],
  Gemini include_thoughts spike, ModelCapabilities.supports_
  reasoning_replay.
* Phase 4 -- Local-model / chat-template reasoning persistence
  (session.py:3486 reasoning_parts accumulator).

Tests

* AnthropicProvider.extract_reasoning_text -- 13 unit tests covering
  None / empty / mixed / multi-block / cap / malformed / non-list
  inputs plus other-provider no-op verification (real provider
  instances, no mocks).
* extract_reasoning_for_history -- 10 dispatcher tests including
  block-type discriminator routing (thinking vs reasoning vs
  unknown), strip-when-flag-false, empty / non-dict guards, and
  cross-role isolation.
* _build_history -- 6 boundary tests through the real Anthropic
  extractor with stub sessions, including the registry-lookup
  failure default-True branch.
* make_history_handler -- 5 round-trip tests through real storage:
  the storage layer's reconstruct_messages decodes provider_data
  into _provider_content, and the helper extracts through the real
  AnthropicProvider. Includes the live-session flag honoring path,
  the cold-workstream workstream_config lookup path, and the
  no-alias default-True fallback path.
* Audit-log discipline -- 4 structural mock-and-assert tests that
  capture every Logger.info / warning / error call across the
  pipeline (extractor, dispatcher, list-helper, _build_history)
  and assert no captured payload contains a marker reasoning string.
* model_definitions storage -- 6 round-trip tests: default flags,
  explicit create with both flags, individual update of each flag,
  and list-includes-flags assertion.
* model_registry -- 4 tests: dataclass defaults, dataclass with
  explicit flags, DB-row-mapping with both flags, and pre-052
  legacy-row default-fallback.

Edge cases pinned by the test suite

* Pre-052 DB rows missing the new columns degrade to dataclass
  defaults (test_db_reasoning_flags_default_when_absent).
* Live session in memory has its flag honored (test_history_handler_
  with_persist_flag_false_via_live_session).
* Cold workstream resolves the flag via workstream_config +
  app.state.registry (test_history_handler_cold_workstream_resolves_
  via_workstream_config) -- this closes the gap where a process
  restart would have silently un-honored an operator flag-flip.
* Cold workstream without persisted model_alias falls through to
  default True (test_history_handler_cold_workstream_no_alias_
  defaults_true).
* Foreign / unknown / missing block types degrade silently to no
  reasoning field rather than misroute or crash.

Lint + test gate

* ruff check + ruff format -- clean.
* mypy -- no issues across all 191 source files.
* pytest -m 'not live' -- 6030 passed (3 deselected).
2026-05-09 02:45:13 -07:00
Patrick Buckley f5e8488ddc style(sse): align comments with always-advance seq invariant
Doc-debt cleanup flagged by /review on 9dc29db7. The cap+seq fix
flipped the seq-advance rule but left two doc sites describing the
old "incremented only on actual append" shape — exactly the buggy
invariant the previous commit removed. Future readers trusting the
stale docs would be one wrong assumption away from re-introducing
the silent-drop bug.

Updates the field-init comment block and the docstring on
register_listener_with_in_progress_snapshot (which sits at the
snap_seq capture site, so its contract is consumer-facing).

Also drops the now-dead `seq: int = 0` initializer in
on_reasoning_token and on_content_token — under the new shape, the
unconditional `seq = self._ws_inflight_seq` inside the lock makes
the initializer unreachable. Was load-bearing under the old
else-branch; harmless now but signals "some path leaves seq at 0"
to a reader.
2026-05-08 18:23:38 -07:00
Patrick Buckley f519ef1036 fix(sse): always advance _ws_inflight_seq on emit, even past cap
Copilot caught a real bug in the cap+seq interaction: the previous
shape only advanced ``_ws_inflight_seq`` when the buffer actually
appended, on the theory that "every _seq corresponds to a buffered
fragment" was a useful invariant. It wasn't — once the buffer hit
its cap, seq stalled at the high-water-pre-cap, so a subscriber that
registered AFTER the cap was hit would capture
``snap_seq == stalled_seq``, and every subsequent live token (also
tagged with the stalled seq) would be filter-dropped by the events
handler's ``seq <= snap_seq`` dedup. Silent loss of the entire
post-cap stream for refresh-past-cap tabs.

Fix: advance seq on every emit, regardless of buffer cap. The cap
is a buffer-size limit, not a stop-streaming signal. Past-cap tokens
are absent from the snapshot's text payload (the buffer was
truncated at cap) but the live stream past them is now correctly
delivered — refresh-after-cap renders snapshot-up-to-cap then live
tokens past it, with a visual gap equal to the past-cap chunk and
no silent drop of subsequent tokens.

Test ``test_inflight_seq_increments_only_on_actual_append`` enforced
the buggy invariant and is renamed/flipped to
``test_inflight_seq_advances_on_every_emit_even_at_cap``. Added
``test_subscriber_after_cap_hit_receives_subsequent_tokens`` (and
the reasoning equivalent) as direct regressions for the
silent-token-loss scenario.
2026-05-08 18:23:38 -07:00
Patrick Buckley 57cb09c871 docs(sse): document state_change + in_progress_snapshot events
Updates the docs that describe the per-workstream SSE event stream and
the SessionUI lifecycle to match the refresh-resume changes:

- api-reference.md: documented the `state_change` event (previously
  undocumented despite already being a live event) and the new
  `in_progress_snapshot` event; rewrote the multi-consumer fan-out
  paragraph to mention the kind-specific replay tail (state_change +
  optional in_progress_snapshot) so the "no catch-up needed" claim
  is no longer misleading.
- architecture.md: bumped the SessionUI Protocol stub to 16 methods
  (added `on_turn_start` / `on_turn_committed`) and pointed at the
  in_progress_snapshot section in the API reference.
- sdk.md: added rows for `state_change`, `in_progress_snapshot`, and
  `approval_resolved` (preexisting gap) to the per-workstream event
  table.
- coordinator-api-tour.md: added an `in_progress_snapshot` row to the
  event table and rewrote the reconnection-contract paragraph to
  cover mid-stream content/reasoning restoration.
- diagrams/04-conversation-turn.puml: added `on_turn_start()` before
  the thinking-start emit and `on_turn_committed()` immediately after
  `messages.append(assistant_msg)`, with notes explaining the inflight-
  buffer reset semantics. PNG regenerated.
2026-05-08 18:23:38 -07:00
Patrick Buckley 29b850919f feat(sse): refresh-resume for mid-stream page reloads
Refreshing a coordinator or interactive workstream pane while the LLM
is mid-stream now restores the partial assistant text + reasoning
immediately and flips the composer back to stop-mode, instead of
showing nothing until the response completes.

Per-turn inflight buffers (`_ws_inflight_content`, `_ws_inflight_reasoning`,
`_ws_inflight_seq`) on `SessionUIBase` are kept separate from the
existing multi-turn `_ws_turn_content` buffer that drives the
dashboard's IDLE-piggyback payload. New `on_turn_start` (top of
send-loop, defensive) and `on_turn_committed` (right after
`messages.append(assistant_msg)`, primary) lifecycle hooks reset
inflight at turn boundaries. The seq counter is monotonic across
turns so a long-lived subscriber's `snap_seq` cutoff stays valid for
the lifetime of the connection — resetting per-turn would silently
drop turn N+1's first M tokens (M = whatever was streamed pre-snapshot
in turn N).

`snapshot_and_consume_state_payload` also drains inflight at idle/error
so cancel and exception paths don't leak stale text. New
`register_listener_with_in_progress_snapshot` atomically registers a
listener and snapshots the inflight buffers; `make_events_handler`
emits a `state_change` event (so the JS busy machine flips to
stop-mode) followed by a one-shot `in_progress_snapshot` after the
kind-specific replay, then strips the internal `_seq` field from
yielded live events while filtering against `snap_seq`. A per-listener
shallow `dict` copy in the live drain prevents the multi-tab race
where one listener's `del event["_seq"]` would corrupt another
listener's filter view.

`_synthesize_cancelled_results` now emits synthetic `on_tool_result`
events for each cancelled tool so live coord tabs can drop the
newly-additive `coord-tool-batch--running` indicator cleanly. The
indicator now coexists with `--auto`/`--approved` (applied on
`tool_info` and `approval_resolved` approved; removed when every row
in the batch has a result), making live tool execution visually
parallel to the replay-time orphan rendering.

Frontend handlers in `app.js` (interactive) and `coordinator.js` (coord)
absorb EventSource auto-reconnect re-replays via a length-based
prefix check on the in-progress buffer. New `InProgressSnapshotEvent`
+ `StateChangeEvent` dataclasses in the Python and TypeScript SDKs
with type guards.

`_MAX_TURN_CONTENT_CHARS` lifted 256 KiB → 512 KiB (single constant
for both buffers — headroom for current commercial models).

Regression tests cover race-free composition under concurrent writers,
seq-filter dedup invariants, the cross-turn seq monotonic invariant,
idle/error inflight drain, synthesized `on_tool_result` on cancel
(including UI-hook failure isolation), and the multi-listener
shared-dict invariant.
2026-05-08 18:23:38 -07:00
Patrick Buckley 34cabb51ce fix(skills): apply Copilot review feedback on PR #495
Two findings, both confirmed against the source:

1. Migration 051's downgrade rewrote every '[]' row back to '{}',
   which would (a) destroy operator-written empty arrays and
   (b) reintroduce the known-invalid sentinel that every consumer
   rejects. Pre-migration '{}' rows and operator-authored '[]' rows
   are indistinguishable after upgrade — there is no clean inverse
   for the data state. Made downgrade an explicit no-op with the
   rationale documented inline; '[]' is the correct shape under any
   consumer's interpretation, so leaving the data untouched on
   downgrade is strictly safer than reversing it. Updated the
   module docstring to call this out.

2. admin_update_skill's notify_on_complete validator short-circuited
   on empty string: `if nc and nc != "[]":` skipped the JSON-parse
   branch when nc=="" and persisted the empty string straight to
   storage, leaving a non-JSON value behind. Folded the empty case
   into the existing "{}" coercion so any blank/whitespace/legacy
   value normalises to "[]" before the array-validation gate.

Tests: three new regressions in TestSkillAPI — empty-string
normalises, "{}" sentinel coerces, non-array JSON 400s. The third
locks in the array-only validator that the previous "valid JSON"
gate would have accepted.
2026-05-08 15:11:47 -07:00
Patrick Buckley 19a0f537d4 fix(skills): notify_on_complete default is "[]" not "{}"
Every consumer of prompt_templates.notify_on_complete treats it as a
JSON-array string (the admin form's array editor, the JSON.isArray
validator in submitEditTemplate, _validate_notify_targets in
server.py, the documented "list of channel/contact identifiers"
shape). But the column's server_default — set in migration 011 and
inherited through 021's lift into prompt_templates — has been "{}"
(an empty JSON object) since day one.

Newly-installed remote skills inherit the schema default, so every
unlock-then-edit flow trips the array validator on the inherited
"{}" and the request never leaves the browser. The user-visible
symptom was "click Save, nothing happens"; the latent symptom was
silent shape divergence between every install and every operator-
authored skill.

Migration 051: rewrites every legacy "{}" row to "[]". Operator-
edited values (anything that's neither "{}" nor NULL) are left
intact. Downgrade restores "{}" only on rows still holding the
post-migration "[]" so any later operator edits stick.

Server-side defaults flipped to "[]" in the same PR so new rows
land correct without depending on the column's server_default:

- _schema.py prompt_templates.notify_on_complete server_default
- StorageBackend protocol create_prompt_template kwarg
- sqlite + postgres create_prompt_template kwargs
- console_schemas.py SkillCreateRequest / SkillUpdateRequest /
  SkillInfo Pydantic defaults
- core/session.py ChatSession._notify_on_complete initial value
- server.py initial-message worker fallback when skill_data omits
  the field

admin_update_skill validator now also rejects non-array JSON (was
"valid JSON" only — would have accepted "{}" or "{\"a\": 1}").
_skill_to_response coerces legacy "{}" rows to "[]" on read so the
admin UI sees a consistent shape even before migration 051 runs.
The frontend's `tmpl.notify_on_complete || "[]"` fallback already
handled empty-string but not "{}" — the read-side coercion makes
it moot.
2026-05-08 15:11:47 -07:00
Patrick Buckley 75820ad495 fix(ui): aria-atomic on modal error elements + drop stale inline display
Designer-review follow-up to the .is-visible sweep. With role=alert
+ aria-live=assertive, AT engines re-announce when the element's
text content changes — but without aria-atomic some engines only
read the diff between old and new content. With aria-atomic=true
the entire updated message is read each time, which matters when a
validation error is replaced by a server error on retry (or
vice-versa).

Added aria-atomic=true to all 24 modal error elements (every
role=alert with aria-live=assertive). Same accessibility uplift
across the board — no per-modal exceptions.

Also dropped the stale `style="display: none"` attribute from the
three MCP error elements (mcp-create-error, mcp-import-error,
mcp-install-error). The CSS rule

  .admin-modal [role="alert"] { display: none; }

already hides them by default — the inline attribute was redundant
and would have overridden the .is-visible toggle if the class-based
contract is ever changed.
2026-05-08 15:11:11 -07:00
Patrick Buckley f4146493fb fix(ui): convert remaining modal-error toggles to .is-visible class
Sweep of the latent bug PR #494 fixed for the skill modals: the
project's CSS contract for modal errors is

  .admin-modal [role="alert"]            { display: none; }
  .admin-modal [role="alert"].is-visible { display: block; }

…but ~30 sites across governance.js and admin.js were toggling
`style.display = ""` instead of the .is-visible class. The "show"
side broke silently — clearing the inline style fell back to the
CSS `display: none` so the error never rendered, and any
validation failure looked like an unresponsive button.

Mechanical conversion of every show/hide site for these modal
error elements:

  governance.js
    create-role-error, edit-role-error
    create-policy-error, edit-policy-error
    github-import-error
    cpp-error, epp-error  (custom + eval prompt policies)
    create-hr-error, edit-hr-error  (heuristic rules)
    create-ogp-error, edit-ogp-error  (output-guard patterns)

  admin.js
    mcp-create-error, mcp-import-error, mcp-install-error

Plus the global `_showModalError` helper in admin.js — its
`style.display = "block"` happened to work today (inline display
beats the CSS rule), but normalising it to .is-visible keeps every
modal on a single canonical path. The five modals that route their
show side through that helper (create-user, create-token,
create-channel, create-schedule, edit-schedule) had their hide
sides converted in lockstep.

Added a comment on `_showModalError` documenting the contract so
the next contributor doesn't reintroduce the bug.

Out of scope: model-create-error (already canonical), home-coord-error
(not in .admin-modal), edit/create-template-error (fixed in #494).
No CSS or HTML changes; behaviour-equivalent for hide sides; show
sides go from broken-silent-no-render to correct-render-with-AT-
announcement.
2026-05-08 15:11:11 -07:00
Patrick Buckley 046f3d185b fix(skills-ui): clear prior error at submit-start so it doesn't go stale
Once edit-template-error is actually visible (the visibility fix in
this same PR), a stale error now persists across resubmit cycles:
the user sees a red message, fixes the input, clicks Save, the
validator passes, the PUT goes out — and the previous error stays
on-screen the whole time, only clearing when the modal closes on
success.

Fix at the start of submitEditTemplate / submitCreateTemplate:
clear .is-visible AND empty textContent. Cheaper than tracking
every validator branch and every .catch path; a fresh submit is a
clean slate.
2026-05-08 14:51:46 -07:00
Patrick Buckley 030363c9c4 fix(skills-ui): show validation errors via .is-visible, not style.display
Smoke-testing the unlock flow surfaced a latent bug: clicking Save
on the edit-skill modal silently no-op'd whenever the
notify-on-complete field had non-JSON content. The error div was
DOM-correct (text content set, role=alert, aria-live=assertive),
but invisible — because the project's modal-error CSS contract is:

  .admin-modal [role="alert"]              { display: none; }
  .admin-modal [role="alert"].is-visible   { display: block; }

…and the JS in submitEditTemplate / submitCreateTemplate was
clearing the inline `display: none` via `el.style.display = ""`.
That falls back to the CSS rule, which still says `display: none`,
so the error never rendered. The user saw no error and the click
felt unresponsive (compounded by the early-return before the
disabled-state reset, which also made Save look broken).

Fixed both skill-modal flows (create + edit) by toggling the
canonical `.is-visible` class instead. Six sites in governance.js:
the two early-return show paths, the two .catch show paths, and
the two modal-open hide-resets.

Scope note: this same bug pattern exists in ~20 other modal error
sites across governance.js and admin.js (create-role, edit-role,
create-policy, edit-policy, github-import, cpp, epp, create-hr,
edit-hr, create-ogp, edit-ogp, mcp-create, mcp-import, mcp-install,
plus admin.js sites that don't go through _showModalError). All
pre-existing, broken silently for who knows how long. Out of scope
for this PR — recommend a follow-up sweep that also normalises
_showModalError's `style.display = "block"` to the same convention.
2026-05-08 14:51:46 -07:00
Patrick Buckley e35a9bacbf fix(skills): apply designer review on lock-icon UX
Designer review of the cb5fa1b lock-icon iteration flagged five
items; four are addressed here, one was a deliberate trade-off
documented below.

- Glyph hardening (#2): the lock character is now 🔒︎ — U+1F512 with
  the U+FE0E text variation selector — paired with the existing
  font-variant-emoji: text rule. font-variant-emoji shipped late
  and isn't universal yet (Chrome 131+, Safari 16.4+, Firefox 132+);
  the explicit text VS is belt-and-braces so older Chromium / most
  Linux don't fall back to a coloured emoji that would clash with
  the monochrome instrument-panel aesthetic.
- Accent-line de-conflict (#3): top:14px → 18px so the lock button
  sits below the modal's ::before accent-line decoration's visual
  band rather than competing with it horizontally. h2's
  padding-right reservation (44px) still gives the title clearance.
- Mobile touch target (#4): @media (max-width: 700px) bumps the
  button to 44×44 (WCAG 2.5.5 / Apple HIG / Material minimum) and
  shifts it to top:8px right:8px, with h2 padding-right widened to
  56px to match.
- Keyboard discoverability (#6): on readonly open, focus lands on
  the lock button instead of Cancel. Keyboard users hit the unlock
  affordance immediately instead of having to Tab past every
  disabled spec input to reach it. Cancel is one Shift-Tab away.

Deferred:
- (#1) Reviewer flagged top-right placement as risking confusion
  with the universal × close-button convention. Keeping the
  icon-only design per product direction; the bordered chip styling
  + accent-coloured hover make it visually distinct from the
  thin-stroke unbordered × pattern, and the confirm dialog catches
  any misclick safely.
- (#5) Optional empty-corner indicator after unlock — the
  "Customized from upstream" badge text already carries the signal;
  not adding new chrome.
2026-05-08 14:32:10 -07:00
Patrick Buckley c8b7dc56f6 fix(skills): unlock UX — lock icon top-right, save reset, confirm z-index
Three issues from manual smoke-testing the unlock flow:

1. Confirm dialog rendered behind the edit-skill modal. Both
   overlays sat at z-index 600, and confirm-overlay is earlier in
   the DOM than edit-template-overlay — so DOM order put the parent
   modal on top of its own confirm. Bumped confirm-overlay to 650
   (still below toasts at 700) since confirm dialogs are launched
   FROM other overlays and need to sit above them.

2. Save button stayed disabled (or non-functional) after unlock.
   submitEditTemplate disables etm-submit on click and re-enables in
   .finally, but a stale disabled=true survives the mutate-in-place
   re-render that runs after unlock. Always reset
   submitBtn.disabled = false in showEditTemplateModal so the
   re-render path can never inherit a stuck disabled state.

3. UX redesign — moved the unlock affordance from a "Customize…"
   button at the bottom of the footer to a 🔒 icon button at the
   top-right of the modal. The lock glyph is the universal "this is
   locked, click to unlock" affordance and reads more clearly than
   a footer button next to Cancel/Save. font-variant-emoji: text
   keeps it monochrome on browsers that support it (instrument-panel
   aesthetic) with graceful fallback to coloured emoji elsewhere.
   admin-modal-skill h2 reserves padding-right so a long title can
   never collide with the absolute-positioned button.

Cleanup: removed the now-unused .modal-secondary and
.modal-buttons-spacer rules; the bottom etm-unlock button + flex
spacer are gone from the modal footer.
2026-05-08 14:32:10 -07:00
Patrick Buckley 6f92d4bcfe fix(skills): match readonly column int idiom in postgres unlock_skill
Copilot caught that prompt_templates.readonly is an Integer column
(_schema.py: sa.Column("readonly", sa.Integer, nullable=False,
server_default="0")) and create_prompt_template stores it as 1/0,
but unlock_skill in the postgres backend was passing a Python bool
(readonly=False). The sqlite impl already uses 0; this aligns the
two backends and matches the 0/1 idiom used for the sibling flag
columns (is_default, auto_approve, enabled).

The other Copilot findings on this PR (loadGovSkills race, NBSP
double-space, list_skill_versions O(history_size), ignored
set_skill_readonly return value + None re-read) were all closed by
the prior review-feedback commit (eea795d): the snapshot+flip is
now an atomic unlock_skill() that uses SELECT MAX(version)+1
internally, the handler guards both the unlock_skill return and the
post-flip get_prompt_template re-read, the JS chains
showEditTemplateModal off loadGovSkills's promise, and the badge
NBSP matches the sibling pattern.
2026-05-08 14:32:10 -07:00
Patrick Buckley 8a644e0906 fix(skills): apply review feedback on unlock action
Code review caught a race + a missing None guard; designer review
caught a window.confirm regression and a button-hierarchy issue.

Backend:
- Race fix (bug-2): replace set_skill_readonly+create_skill_version
  with a single atomic unlock_skill(template_id, snapshot, changed_by)
  -> int|None on the storage protocol (sqlite + postgres). Snapshot
  insert + readonly flip happen in one transaction; the next version
  number is computed via SELECT MAX(version)+1 inside the txn rather
  than len(list)+1 outside, closing the (skill_id, version)
  collision window where two concurrent admin actions could both pick
  the same version.
- None guard (bug-3): check the post-flip get_prompt_template re-read;
  return 404 instead of letting _skill_to_response(None) raise.
- Audit body: also record snapshot_version, and harden None-vs-empty
  with `or ""` on the existing.get(...) calls.

Frontend:
- D-1: replace window.confirm with the existing showConfirmModal
  (admin.js:2350) — themed dialog, focus-trap, can render the source
  URL with consistent typography. The native dialog could collapse
  the multi-paragraph copy depending on browser.
- D-2: mutate-in-place on success rather than hide → reload → reopen.
  loadGovSkills now returns its fetch promise so unlockSkill can
  chain showEditTemplateModal after the cache refresh — no flicker,
  no focus bounce, and it kills bug-1 (the reopen was reading stale
  _govSkills before loadGovSkills resolved). showEditTemplateModal
  is idempotent when already open: it skips the trigger-element
  capture and the focus-trap reinstall.
- D-3: button hierarchy. Drop flex:1 from .modal-secondary so the
  Save button keeps a stable width whether or not Customize is
  rendered; insert a flex-spacer between Customize and Save so the
  destructive-ish detach groups left next to Cancel and the primary
  action floats right.
- D-4: NBSP normalized to match the existing   escape pattern
  on the sibling badge line (was an actual NBSP byte).
- D-5: success toast now reads "Skill unlocked — fields are now
  editable" so the operator gets a positive affirmation that the
  edit affordance is live.
- D-10: aria-describedby="etm-origin-badge" on disabled spec inputs
  so screen-reader users get the same "this came from upstream"
  context that sighted users see in the cyan badge.

Tests: + test_unlock_skill_versions_after_existing_history seeds an
out-of-order version (3) and asserts unlock picks 4, defending
against the len()-based version computation regressing.
2026-05-08 14:32:10 -07:00
Patrick Buckley 13b8dd69a7 feat(skills): unlock action lets operators customize installed skills
skills.sh / GitHub installs land with readonly=True so admins can only
tune runtime config (model, temperature, etc.); the SKILL.md spec is
locked. In practice, upstream skills aren't always tuned for turnstone,
so locking the spec adds friction without a real safety win — every
edit is audited and version-snapshotted regardless.

This adds an explicit unlock so the boundary stays visible (multi-user
audit trail benefits from a discrete event, vs. silently dropping the
gate). Behaviour:

- POST /v1/api/admin/skills/{id}/unlock — flips readonly=False on a
  readonly row. Snapshots the pre-unlock state into skill_versions so
  the upstream-pristine version is recoverable from the History tab.
  Records skill.unlock audit with {name, source_url, origin}. 400 on
  already-unlocked, 404 on missing.
- origin stays "source" after unlock so the UI keeps a "Customized
  from upstream" provenance badge — the readonly flag is the gate, the
  origin field is the lineage.
- Storage: dedicated set_skill_readonly writer on the protocol +
  sqlite + postgres backends. readonly is intentionally absent from
  SKILL_MUTABLE so the generic update path can't piggyback on a
  provenance flip — the dedicated writer pattern matches what's
  already used for set_mcp_oauth_client_secret_ct.
- Frontend: "Customize…" button in the edit modal (visible only when
  readonly), with a confirm dialog explaining the upstream-detach.
  Once unlocked the existing edit-skill flow handles spec edits with
  no other changes. Origin badge updates to show "Customized from"
  the upstream URL when a source-origin row is unlocked.

Tests cover: unlock flips readonly + persists, pre-unlock snapshot
written to skill_versions, 400 on already-unlocked, 404 on missing,
post-unlock PUT can edit name/content/description (the readonly gate
no longer fires).
2026-05-08 14:32:10 -07:00
Patrick Buckley 72f9abd84c fix(skills): apply PR #491 review feedback (size cap + dedup + conflict mapping)
Three issues caught by Copilot on the initial PR:

1. SKILL.md size cap was measured in code points, not UTF-8 bytes.
   `len(str)` is a *lower* bound on encoded byte length — multi-byte
   chars (emoji, CJK) inflate up to 4×, so a 100k-emoji SKILL.md
   (400KB encoded) would slip past the 256KB cap. Switch to
   `len(contents.encode("utf-8"))` and surface lone-surrogate failures
   as SkillSourceError instead of dropping them silently. New
   regression test feeds emoji content.

2. _skills_sh_source_url did not normalize the skill_id, so a sloppy
   id from `/api/search` (whitespace, surrounding slashes) would pass
   `_split_skills_sh_id`'s charset check (which strips first) and
   produce a malformed persisted source_url that broke the
   discover-UI dedup contract. Strip the id inside the helper, and
   reconstruct the canonical id from validated parts in
   download_skill's listing so downstream callers never see the raw
   input.

3. The catch-all `except Exception:` around create_prompt_template
   relabeled every storage failure (DB connection, disk full,
   permission errors) as "conflict", masking operational issues.
   Translate IntegrityError → StorageConflictError at the storage
   shim (matching the pattern already used for OIDC user
   provisioning) in both sqlite and postgres backends, then catch
   StorageConflictError specifically in the install handler. Real
   conflicts → "conflict" + warning; other exceptions → new
   "internal error" reason + log.exception.

Tests: +3 (oversized multibyte SKILL.md, source_url normalization,
storage-layer conflict translation). 226 passing.
2026-05-08 13:58:02 -07:00
Patrick Buckley 15f7c7499c fix(skills): switch skills.sh install to /api/download endpoint
The skills.sh install path was failing with 404s because their public
API surface changed: /api/skills/{id} is gone, replaced by
/api/skill/[owner]/[repo]/[skill] (auth-walled) and
/api/download/[owner]/[repo]/[skill] (unauthenticated, returns the
SKILL.md + bundled resources inline as JSON). The error was not
surfacing in logs because admin_skill_install had a silent
`except Exception:` around create_prompt_template that relabeled every
storage failure as "conflict" with no log entry.

- Replace SkillsShClient.resolve_github_url with download_skill that
  hits /api/download/{owner}/{repo}/{skill} and returns a SkillPackage
  directly. No GitHub round-trip; no rate-limit surface.
- Add _split_skills_sh_id with strict per-segment charset validation
  ([A-Za-z0-9._-]+) so URL-hostile content can't produce a malformed
  request or divergent persisted source_url.
- Use len(contents) instead of len(contents.encode("utf-8",
  errors="ignore")) for the SKILL.md size cap — errors='ignore' was
  silently dropping invalid units, making the cap bypassable.
- Extract _accept_resource(rel_path, byte_size) gate predicate; share
  it between download_skill and the GitHub _find_resource_files helper.
- Have search() derive a deterministic source_url from the skill id
  when /api/search omits one (which it currently always does), so the
  discover-UI "already installed" check matches what download_skill
  persists.
- Add structured logging across admin_skill_install and
  admin_skill_discover: a shared _log_install_failure helper for the
  four except branches (was four near-duplicate log calls with one
  drift), plus per-resource failure tallying — partial-resource
  installs now surface failed_resources in the response and audit
  record instead of silently committing the skill row with missing
  assets.

Tests: 7 new — empty/non-list files, oversized SKILL.md, resource
cap, non-text extension filtering, plus _split_skills_sh_id charset
rejection (whitespace, query chars). Verified end-to-end against
live skills.sh with tavily-search.
2026-05-08 13:58:02 -07:00
Patrick Buckley 6abb2698f7 fix: apply repair=False to all display-read load_messages call sites 2026-05-07 22:46:04 -07:00
Patrick Buckley c2cb6a7ea5 fix(replay): apply PR #488 review findings
Four Copilot findings on c6041c6 — all confirmed valid, all bounded
to authenticated-user prompt-injection scenarios but worth closing
before merge.

Wrapper-detect bypass (string + list branches of
``_apply_reminders_for_provider``):

The round-2 fix used ``content.startswith("<tool_output>\\n")`` to
detect already-wrapped content and skip ``escape_wrapper_tags``.  A
tool whose RAW output starts with that prefix (e.g. ``echo
'<tool_output>'``) would match and have its escape skipped, letting
literal ``<tool_output>`` / ``<system-reminder>`` tags reach the model
and impersonate a system envelope.  Replace the prefix check with
``extract_advisories_from_tool_envelope(content) is not None`` —
parsing requires the open AND matching close tags AND a structurally
valid envelope, raising the bypass bar significantly.

Mirror fix in the list-content branch so a tool emitting an unmatched
envelope as a text part can't bypass the per-text-part escape.

``_build_history`` legitimate-envelope drop:

The list-content drop path previously removed any text part starting
with ``<tool_output>\\n``.  A tool that legitimately outputs a
well-formed envelope (documentation viewer, code analyzer demoing the
wrapper, an echo tool) would have that part silently disappear on
replay.  Tighten the drop heuristic to require BOTH ``cleaned_text ==
""`` AND at least one extracted advisory — the structural signature of
the injected ``wrap_tool_result("", advisories)`` carrier we produce
in ``session.py`` for list-typed tool output.  A legitimate envelope
has non-empty inner body or no advisory blocks and survives the
projection.

Empty advisory body:

``queue_message`` accepts any non-None text including ``""`` and
whitespace-only strings.  ``_classify_advisory`` would return a
``user_interjection`` advisory with empty / whitespace body, which
``replayAdvisoriesAfterTool`` then renders as a featureless empty user
bubble.  Filter empty / whitespace-only bodies at classification time
so the wire-shape contract is uniform: no empty advisories ever ride
the wire.

Tests:

* ``test_apply_reminders_escapes_tool_output_starting_with_envelope_prefix``
  pins the structural-parser bypass close: a string starting with the
  envelope prefix but lacking a close tag still gets escaped.
* ``test_apply_reminders_escapes_list_text_part_with_unmatched_envelope_prefix``
  mirrors for the list-content branch.
* ``test_build_history_keeps_legitimate_envelope_text_part_with_body``
  pins that legitimate envelope output stays in the projected list.
* ``test_decorate_suppresses_empty_advisory_body`` and
  ``test_decorate_suppresses_whitespace_only_advisory_body`` pin the
  empty-body filter in ``_classify_advisory``.

Tests: 5923 passed, 3 deselected.  Lint + format + mypy clean.
2026-05-07 17:32:23 -07:00
Patrick Buckley eca4bb79e4 fix(replay): seam 1 splice + storage symmetry for queued user messages
Reverses the seam-2-only design from the prior commits on this branch.
Queued user messages arriving DURING a tool batch (Seam 1) splice into
the last tool result's envelope as ``UserInterjection`` advisories via
``wrap_tool_result``.  Messages arriving BETWEEN turns (Seam 2) drain
as a single trailing user row via ``_flush_queued_messages`` with
``user_feedback`` (operator text alongside an approval, e.g. "y, use
full path") folded in as a prefix.  Cancel/exception drains (Seam 3)
keep the existing ``_flush_queued_messages()`` call unchanged.

Why all three seams:

* Strict-template providers (Mistral, Llama via vLLM with stock chat
  templates) reject role-alternation violations.  A literal ``user``
  row mid-tool-batch breaks ``assistant(tool_calls) → tool → ... →
  assistant``; back-to-back ``user → user`` rows on the wire also fail.
* The seam-2-only design produced back-to-back ``user`` whenever
  ``user_feedback`` and queued items both fired — bug-1 from the round-1
  review.  Folding ``user_feedback`` as a prefix to the queue-drain
  collapses the two into one row.
* During-batch arrivals couldn't ride seam 2 — the splice was the only
  way to deliver same-turn without violating role alternation.

Storage symmetry:

Tool DB rows now store the wrapped ``output`` (envelope + advisories)
unconditionally — ``self.messages[i]['content']`` and
``conversations.content`` match exactly.  List-typed output (image /
structured MCP results) uses ``wrap_tool_result(raw_joined_text,
advisories)`` at save time so the persisted string is anchored on
``<tool_output>\n`` for the replay parser.  ``TOOL_RESULT_STORAGE_CAP``
is removed entirely; tools are responsible for bounding their own
output, storage faithfully represents in-memory.  Removing the cap
also simplifies the parser — no truncated-envelope edge case.

Replay extraction:

``decorate_history_messages`` (REST ``/history``) and ``_build_history``
(SSE replay, resume, rewind, retry, post-load, rename re-replay) both
call the public ``extract_advisories_from_tool_envelope`` helper to
pull the envelope back into structured ``advisories`` for JS replay.
Both string content and list-typed content (image+queued-message
combo) covered.  JS renders extracted advisories as normal user
bubbles after the tool block via the shared ``replayAdvisoriesAfterTool``
helper in ``shared_static/utils.js``.

Wrapper-tag escape and provider splice:

``escape_wrapper_tags`` now encodes pre-existing ``&`` first using an
``&amp;`` sentinel so tool output containing literal entity strings
(documentation viewers, code analyzers, web scrapers returning entity-
encoded markup) round-trips correctly.  Both encode and decode helpers
short-circuit on absence of ``<`` / ``&``.

``_apply_reminders_for_provider`` detects already-wrapped content
(string body and list text-part) by ``startswith("<tool_output>\n")``
and skips re-escape so existing envelopes survive intact when a tool
message also carries ``_reminders`` (the queued-message + tool-error
co-occurrence case is now common).

``decorate_history_messages`` runs in ``asyncio.to_thread`` to keep
MB-scale string work off the event loop.

Other cleanup:

* ``_collect_advisories`` delegates the queue drain to a named helper
  ``_drain_queued_messages_to_advisories`` so the swap-and-clear pattern
  lives next to ``_flush_queued_messages``'s identical pattern and the
  side-effect is documented at the call site.
* Preamble strings + body marker for ``UserInterjection`` round-trip
  detection moved to module-level constants in ``tool_advisory.py``;
  imported by ``history_decoration.py`` so a producer-side rephrase
  can't silently desync the parser.
* ``_send_with_mocks`` ctxmgr extracted in ``test_session.py`` — the
  six new send-driven tests share an 8-deep ``patch.object`` block.
* ``replayAdvisoriesAfterTool`` shared helper in
  ``shared_static/utils.js``; ``app.js`` and ``coordinator.js`` both
  invoke it.
* Dead truncation-pill CSS removed (``.tool-output-truncated`` and
  ``.coord-tool-truncated``); the JS that added these elements went
  away with ``TOOL_RESULT_STORAGE_CAP``.
* Tautological tests (``TestBuildHistoryAdvisoryPropagation``)
  replaced with production-realistic round-trip tests built from
  ``wrap_tool_result(...)`` envelopes — REST and SSE-replay surfaces
  pinned to the same wire shape; full DB round-trip pinned end-to-end.

Negative-tested:

* Reverting the prefix-merge in ``_flush_queued_messages`` produces
  back-to-back ``user`` rows, breaking
  ``test_user_feedback_and_queued_coexistence_single_row_with_prefix``.
* Reverting the ``extract_advisories_from_tool_envelope`` call in
  ``_build_history``'s tool branch leaves the envelope verbatim in
  wire content, breaking the round-trip tests.
* Reverting the wrapper-detection in ``_apply_reminders_for_provider``
  entity-encodes the existing envelope's literal tags, breaking both
  the string-content and list-content envelope-preservation tests.
* Reverting the ``wrap_tool_result(raw_text, advisories)`` projection
  at the DB save site produces a string starting with the original
  raw text, breaking
  ``test_tool_db_row_round_trips_list_output_with_advisories``.

Tests: 5918 passed, 3 deselected.  Lint + format + mypy clean on
touched files.
2026-05-07 17:32:23 -07:00
Patrick Buckley a032e71ff3 fix(replay): apply review findings q-2 through q-7
Round-1 ``/review`` apply-pass.  Drops stale ``UserInterjection``
references from comments and docstrings that no longer describe the
post-PR drain shape, asserts the two-stream invariant in the new
queued-message persistence test, and pins the ``content.trim()`` +
``renderAssistantToolBatch`` invariants on coord-side so a future
refactor can't silently regress the Qwen3 phantom-card fix or the
chronological-order render fix.

Deferred:

* **bug-1** (back-to-back ``user`` row when ``user_feedback`` from the
  approval-prompt UI callback coexists with a queued-message drain).
  Reachable on strict OpenAI-compatible local templates (Anthropic and
  Anthropic-via-merge-consecutive collapse fine; vLLM-hosted Mistral /
  Llama enforcing role alternation can reject).  The pre-PR splice
  guarded against this case by riding queued items inside the tool
  result envelope; that guard is what motivated the original
  UserInterjection design, so the fix lane needs a deliberate decision
  rather than a quick patch.  Sleeping on it.

* **q-1** (delete dead ``UserInterjection`` class + tests).  Held for
  the bug-1 decision — if the chosen fix is to resume the splice for
  the ``user_feedback``+queue coexistence case, the advisory shape
  stays load-bearing.  Class now carries a docstring note marking it
  retained-pending-decision so a passing reader doesn't grep for
  producers and assume it's actually dead.

Apply-pass content:

* ``q-2``: drop "queued user interjections" from the persistent-
  advisory parenthetical in ``send``'s tool-result loop comment;
  rewrite to point at ``_flush_queued_messages`` for the queue path.
* ``q-3``: ``__init__`` channel-routing comment loses "and
  ``UserInterjection``" — only ``GuardAdvisory`` remains.
* ``q-4``: ``_queue_tool_advisory`` docstring + the tool-error nudge
  comment lose the user-interjection mentions; the docstring also now
  describes the side-channel + ``_apply_reminders_for_provider``
  splice path (the actual mechanism).
* ``q-5``: ``AttachmentsNotQueueableError`` docstring rewritten to
  describe the post-PR ``_flush_queued_messages`` flow — the
  single-combined-turn ``\n\n``-join shape can't carry image / file
  blocks, and per-item separate user turns would expand the strict-
  template role-ordering surface that the post-batch drain already
  balances.
* ``q-6``: the new ``test_queued_message_persists_as_user_row_after_tool_batch``
  in ``test_session.py`` now asserts ``stream_idx == 2`` so a future
  regression where the post-batch flush runs but the send-loop short-
  circuits before the next iteration surfaces in CI rather than
  manual repro.
* ``q-7``: ``test_coordinator_page.py`` gets two new string-grep pins
  mirroring the existing ``test_app_js.py`` shape — ``content.trim()``
  on coord's assistant-replay branch and ``renderAssistantToolBatch``
  for the hoisted helper that orders content card before tool batch.

## Test plan

- [x] ``ruff check`` clean
- [x] ``mypy turnstone/`` clean (189 source files)
- [x] Affected test surface (``test_session.py`` +
  ``test_tool_advisory.py`` + ``test_app_js.py`` +
  ``test_coordinator_page.py``) — 240 passed
2026-05-07 17:32:23 -07:00
Patrick Buckley c11692b327 fix(replay): coord render order + blank assistant cards + queued message persistence
Three independent rehydrate / replay regressions reported on long
multi-turn conversations after the pull-model wake stack landed.

**1. coord history replay rendered tool_calls above the assistant
narration that announced them.**

In ``coordinator.js``'s loadHistory loop, the ``role === "assistant"``
``tool_calls`` branch sat above the role switch — every assistant turn
with both narration AND tool dispatch produced ``[tool batch][content
card]`` in the DOM, even though chronological order is content first.
On a parallel fan-out (e.g. four ``close_workstream`` calls in one
turn) operators saw the assistant text "Let me close them out and
summarize" with NO tool batch between it and the next assistant
message — the four-row batch had been rendered above the announcing
text and was scrolled out of view.

Hoisted the ``tool_calls`` synthesis into a local
``renderAssistantToolBatch(m)``, called from inside the assistant
branch AFTER the content card.  Live SSE order (text → dispatch →
results) now matches replay order.

**2. Whitespace-only assistant content rendered as a blank card on
replay.**

Models with vLLM's ``--reasoning-parser`` (Qwen3 in production)
strip ``<think>…</think>`` and emit only the trailing ``"\n\n"`` as
``content`` before a tool call.  ``content_parts = ["\n\n"]`` saves
``content = "\n\n"`` to the conversations row.  Live the user only
sees ``.msg.reasoning`` (the thinking content) — the empty
``.msg.assistant`` card lives next to it but reads as a thin
divider.  On rehydrate the reasoning bubble is gone (not persisted)
and the empty assistant card is the only thing left, surfacing as
"blank cards where the assistant message was."

Both UIs now check ``content && content.trim()`` before rendering
the body — whitespace-only content skips the card entirely instead
of showing a phantom row.  Live render unchanged.

**3. Queued user messages disappeared on reconnect.**

PR #474 routed queued user messages into the tool-result envelope
via ``UserInterjection`` advisories — same-turn delivery, but no
persisted user row.  On page reload / cross-tab replay the
optimistic ``.msg-queued`` bubble vanished: there was no DB row to
rehydrate it.

Dropped the ``UserInterjection`` splice in ``_collect_advisories``;
the queue drains through ``_flush_queued_messages`` AFTER the tool
batch completes instead.  Sequence becomes
``assistant(tool_calls) → tool … tool → user(drained)``, which is
valid for Mistral and Anthropic strict role validators (the only
forbidden shape was user injected mid-batch BEFORE the tool result,
which this still avoids).  Persists a real user row → bubble survives
reconnect, and stays in the session's wire-side context window on
the next turn.

## Test plan

- [x] ``ruff check`` clean
- [x] ``mypy turnstone/`` clean (189 source files)
- [x] ``pytest -m "not live"`` — 5798 passed, 3 deselected
- [x] Updated ``test_collect_advisories_does_not_drain_queued_messages``
  (was pinning the old UserInterjection shape)
- [x] Added ``test_queued_message_persists_as_user_row_after_tool_batch``
  (drives ``send`` end-to-end with a queued message arriving during
  the tool batch; asserts the user row lands in self.messages AND
  hits ``save_message``)
- [x] Updated ``test_replay_history_renders_content_before_tool_block``
  to tolerate the new ``msg.content && msg.content.trim()`` guard
- [ ] Live browser pass on coord (close_workstream parallel fan-out
  rehydrates with the 4-row batch BETWEEN the announcing assistant
  text and the summary) and interactive (Qwen3 ``"\n\n"`` rows no
  longer paint blank cards on reload; queued bubble survives a tab
  refresh)
2026-05-07 17:32:23 -07:00
Patrick Buckley 4a3e3607be fix(mcp): apply PR #489 review feedback + de-flake pool reuse 401 retry
PR #489 review feedback (Copilot + github-code-quality):
- closeSettingsPanel now closes nested revoke modal first on close-button
  path (Escape was already handled by the parent keydown trap deferring
  to the inner trap; missing-modal-on-close-button was an orphan-modal
  hazard).
- _refreshConsentBadge now updates the settings button's aria-label +
  title dynamically with the pending-consent count for screen readers
  (badge stays aria-hidden — the count is in the label).
- _MAX_INSUFFICIENT_SCOPE_REPORTED promoted to public
  MAX_INSUFFICIENT_SCOPE_REPORTED in mcp_http_parsers; drops cross-module
  private import in mcp_oauth's /start handler.
- Stale test comment in test_session_mcp_dispatch_error.py corrected:
  _exec_read_resource does not log with exc_info=True (bearer-leak
  invariant).
- Rejected the protocol-method ellipsis warning: rest of _protocol.py
  uses ... consistently per Protocol convention.

Lint:
- ruff format applied to test_mcp_pool_auth_integration.py and
  test_mcp_pool_auth_resource_integration.py (combined `with` grammar —
  pure formatting).

Flake fix — test_integration_pool_reuse_401_refresh_and_retry_succeeds
on Python 3.11 / resource-constrained CI:

Same cross-task scope hazard f6a3b66 fixed at the close side, surfacing
at the connect side. asyncio.wait_for at mcp_client.py:1206 wraps
streamablehttp_client.__aenter__ in a fresh asyncio.Task. That fresh
task enters anyio cancel scopes, completes, and dies. The eventual
stack.aclose() during eviction or auth_401 retry runs from a different
task and tries to exit scopes whose entering task is dead — anyio
raises RuntimeError, the wedged anyio state blocks the retry's stack
teardown + reconnect, and the call exceeds the 15s budget on slow
workers.

Fix: replace asyncio.wait_for with `async with asyncio.timeout(...)` so
the streamablehttp_client.__aenter__ runs in the dispatch task itself,
no fresh-task scope ownership. Aligns with invariant 18 (asyncio.timeout
not asyncio.wait_for for any SDK / AS / pool-loop await crossing anyio
scopes).

Static path (_connect_one) at lines 905 and 1000 deliberately retains
asyncio.wait_for — auth_type ∈ {none, static} is byte-identical
(invariant 1) and the narrow connect-once / no-eviction-then-reuse
pattern doesn't trigger the cross-task hazard. Anchor comments pin
both directions: a future migration there would break invariant 1; a
future revert at 1206 would re-introduce the flake.

The cited test is the symptom (non-deterministically times out under
load), not a structural gate (no deterministic asyncio.timeout
assertion exists). The comment block at line 1206 records this so a
maintainer who reverts and finds green on a fast machine doesn't
conclude the fix is unneeded.

Verified on Python 3.11.14 (/tmp/venv311) and 3.13.7 (.venv): ruff
format clean, ruff check clean, mypy clean. 368 unit tests + 30 pool
integration tests pass on both interpreters; the previously-flaky test
passed 20× in isolation on 3.11.

Multi-stage /review (4 finders × verify × dedupe): bug/security/perf
returned zero findings; quality returned 3 confirmed minor/nit items
all of which are applied here (q-1 anchor comments at 905+1000, q-2
symptom-vs-gate clarification at 1206, q-3 module-docstring sentence
in mcp_http_parsers).
2026-05-07 13:59:50 -07:00
Patrick Buckley 5a3f46a1fa feat(mcp): per-user MCP server consent UX (Phase 8)
Wires the structured-error envelopes produced by Phase 7b's pool
dispatcher (mcp_consent_required / mcp_insufficient_scope /
mcp_*_forbidden / mcp_token_undecryptable_key_unknown /
mcp_oauth_url_insecure) through to the user-facing dashboard, and
adds a per-user settings panel for managing MCP server consents.

Changes
- ``_dispatch_pool_sync`` and ``_dispatch_pool_resource_sync`` wrap
  structured-error string returns as ``RuntimeError(json_str)`` via
  ``_is_structured_error()`` so the session-layer ``except Exception``
  branch fires uniformly across tool / resource / prompt dispatchers
  (the prompt path's ``isinstance(result, str)`` shortcut works only
  because prompts return ``list[dict]`` on success). Without this,
  the consent UX silently does not render for tool / resource calls.
- ``_structured_error`` extended with an optional ``consent_url``
  field; ``_build_consent_url`` produces ``/v1/api/mcp/oauth/start``
  query strings (path-relative; the dashboard appends ``return_url``
  at click time). Wired to all 12 ``mcp_consent_required`` and the
  ``mcp_insufficient_scope`` emit sites.
- New endpoints ``GET /v1/api/mcp/oauth/connections`` and
  ``DELETE /v1/api/mcp/oauth/connections/{server_name}`` registered
  on both ``turnstone-server`` and ``turnstone-console``. The DELETE
  handler runs local delete + audit + 204 first, then schedules the
  RFC 7009 upstream revoke as a fire-and-forget ``asyncio.create_task``
  with strong-ref tracking via ``_revoke_upstream_tasks`` (mirrors
  the ``_pg_refresh_drain_tasks`` pattern). Soft cap of 256 concurrent
  in-flight revokes prevents pile-up under coordinated mass-revoke;
  the audit detail records ``upstream_revoke_outcome`` as
  ``scheduled | no_refresh_token | no_http_client | shed_by_cap``.
- ``ASMetadata`` extended with ``revocation_endpoint`` parsed from
  RFC 8414 metadata. ``revoke_token_at_as`` helper posts the form
  body under ``asyncio.timeout`` (not ``asyncio.wait_for``) and
  never raises; ``_attempt_upstream_revoke`` is wrapped in an outer
  ``try/except Exception`` so unhandled exceptions don't surface as
  ``Task exception was never retrieved``.
- ``/v1/api/mcp/oauth/start`` accepts an optional ``scopes=`` query
  param; tokens are validated against RFC 6749 §3.3 grammar via
  ``is_valid_scope_token`` (promoted to ``mcp_http_parsers``),
  capped at ``_MAX_INSUFFICIENT_SCOPE_REPORTED`` (32), and unioned
  with the configured server scopes for the step-up consent flow.
- Storage primitive ``list_mcp_user_token_metadata_by_user`` projects
  the metadata columns at the SQL boundary so ciphertext blobs never
  cross the wire on the settings-list path. New
  ``MCPUserTokenMetadataRow`` TypedDict in ``_protocol.py``;
  ``MCPTokenStore.list_user_token_metadata`` re-types to the existing
  ``MCPUserTokenMetadata`` shape.
- Dashboard renderer (``app.js``): ``tryParseMcpError`` detects the
  envelope shape on ``tool_result`` SSE events with ``is_error=True``
  and ``buildMcpErrorEmbed`` renders an action card mirroring the
  existing ``buildMediaEmbed`` pattern. Three categories: actionable
  (consent_required / insufficient_scope) with a ``Connect`` button
  that opens ``/v1/api/mcp/oauth/start`` in a popup with a scheme
  guard, forbidden (mcp_*_forbidden) with a static notice, operator
  (key-mismatch / url-insecure) with an operator-action notice.
- New gear button in the appbar opens an MCP-connections settings
  modal driven by ``loadMcpConnections`` / ``confirmRevokeMcp``
  (two-step revoke confirmation matching the existing delete-ws
  pattern). Pending-consent badge tracks unresolved consent prompts
  in this tab; cleared after the connections list returns. Console
  proxy collision-checked: the IIFE only prepends a node-id pill to
  ``header.firstChild``, so the right-anchored gear button is safe.

Bearer-leak invariant
- No ``exc_info=True`` on any new path that can carry a chained
  ``httpx.Request`` (revoke handler, dispatch sites, exec sites).
  The two pre-existing ``exc_info=True`` calls in
  ``_exec_read_resource`` / ``_exec_use_prompt`` were replaced with
  structured-field logs as a Phase 8 sibling fix.

Tests
- 440 pytest passes on both Python 3.13 (.venv) and 3.11
  (/tmp/venv311); ruff + mypy clean.
- 5 new test files: ``test_mcp_consent_url_sibling_audit`` (structural
  gate that every ``code="mcp_consent_required"`` / ``mcp_insufficient_scope``
  site carries ``consent_url=``), ``test_mcp_oauth_connections``,
  ``test_mcp_oauth_revoke``, ``test_mcp_token_store_metadata``,
  ``test_session_mcp_dispatch_error``.
- End-to-end regression coverage for the bug-1 sibling pattern:
  ``test_call_tool_sync_raises_on_structured_error_envelope``,
  ``test_read_resource_sync_raises_on_structured_error_envelope``,
  ``test_get_prompt_sync_raises_on_structured_error_envelope``, plus
  ``test_call_tool_sync_does_not_wrap_non_structured_string`` as the
  defensive gate (only ``mcp_*`` envelopes are wrapped).

Hard invariants honored
- Static path byte-identical for ``auth_type ∈ {none, static}``: the
  wrap fires only when the dispatcher returns a structured-mcp-error
  string, which only happens on the oauth_user pool path.
- ``asyncio.timeout`` (not ``asyncio.wait_for``) on every new
  AS / SDK / pool-loop await per Python 3.11 anyio cancel-scope
  hazard.
- Scope cap ``_MAX_INSUFFICIENT_SCOPE_REPORTED = 32`` enforced at
  every output / merge site.
- Cross-user isolation on the revoke endpoint: a non-owner DELETE
  returns 404 with the same body shape as a never-existed row;
  ``http_client_mock.post.assert_not_called()`` pins this in 3 tests.

Deferred (not Phase 8 blockers)
- perf-2 (``asyncio.gather`` parallelisation in revoke handler) —
  superseded by perf-1's fire-and-forget pattern.
- q-4 (prompt-path ``isinstance(str)`` vs sibling ``_is_structured_error``
  asymmetry) — already documented in the function docstring.
- q-9 (``_pendingConsentServers`` → ``_serversNeedingConsent``
  rename) — pure naming taste.
2026-05-07 13:59:50 -07:00
Patrick Buckley fc8bd6ca33 fix(storage): sanitize NUL bytes on _source + _reminders columns
Apply sanitize_text() to the new _source and _reminders columns in
both save_message and save_messages_bulk on SQLite + PostgreSQL,
mirroring the existing pattern used for content and provider_data.

Producers (sanitize_payload on the watch dispatch path,
format_nudge constants on the standard nudge path) already strip
NUL bytes today so nothing in production reaches this clamp — but
the storage layer is opaque to those invariants, and PostgreSQL
TEXT columns reject NUL outright.  Without this clamp, a future
producer that forgets sanitize_payload (or hand-builds the column
string) hard-fails the chat-loop persist path on PostgreSQL.

Cost is negligible — sanitize_text early-exits on the common
no-NUL case via 'if value and "\x00" in value'.

Surfaced by Copilot's PR #486 review.
2026-05-06 23:34:29 -07:00
Patrick Buckley 14af6f464e revert(memory): drop dormant limit kwarg from load_messages
Closes round-2 review finding q-7 (nit).

The kwarg was added to close round-1 perf-2 cosmetically — the
storage backend's signature already accepted ``limit``, but the
single in-tree caller (``ChatSession.resume``) doesn't pass it and
other tail-load consumers go direct to ``storage.load_messages``.
Adding signature surface to mark a perf finding closed without an
actual consumer is API-surface bloat.

When a tail-load consumer is written (e.g. a heuristic in
``session.resume`` to skip ancient wake rows), the kwarg can come
back — at that point with a real caller driving the contract.
2026-05-06 23:34:29 -07:00
Patrick Buckley 668da26dce refactor(watch): rename _WATCH_REMINDER_OPTIONAL_KEYS public + hoist import
Closes round-2 review findings q-6 (nit) and perf-1 (nit).

* **q-6:** ``_WATCH_REMINDER_OPTIONAL_KEYS`` carried a leading
  underscore (Python's module-private convention) but was imported
  from two other modules — clearly a public contract between
  ``build_watch_reminder`` and its consumers
  (``ChatSession._dispatch`` + ``server._build_history``).  Drop the
  underscore so the import sites match the constant's documented
  cross-module role.

* **perf-1:** The dispatch closure imported the constant inside its
  body, paying ``IMPORT_NAME`` + ``IMPORT_FROM`` bytecode on every
  watch fire.  ``server.py`` already imports at module scope; hoist
  the same way in ``session.py``.  Microsecond savings per dispatch,
  but the in-closure form was just an oversight from the apply-pass.
2026-05-06 23:34:29 -07:00
Patrick Buckley b120ee2fd7 fix(session): trim tombstone refs + WHAT-narration in apply-pass comments
Closes round-2 review findings q-1 (minor), q-3 (nit), q-4 (nit), q-5
(nit).

* **q-1:** Drop the ``post-migration 050`` clause from the fork-block
  comment — the apply-pass relocated rather than removed the
  tombstone-style temporal reference round-1 q-2 was supposed to fix.
  The bulk-row dict shape and ``_encode_reminders`` are
  self-explanatory; the WHY is pinned by
  ``test_fork_preserves_source_and_reminders``.

* **q-3:** Replace ``DOES persist now`` framing on the wake-row save
  comment with a present-tense invariant.  The ``now`` implies the
  reader knows the prior state, same family as the temporal
  tombstones.

* **q-4:** Trim the 12-line WHAT-narration block above the
  resume-time ``_reminders_delivered = True`` loop to two lines
  stating the WHY only.  The new regression test pins the contract.

* **q-5:** Reframe ``test_fork_preserves_source_and_reminders``
  docstring as a forward-looking invariant; drop the
  ``Dropping them was the original bug`` and ``post-migration 050``
  fix-narration.

Project convention: invariant statements, present tense; don't
reference the current task / fix / migration number.
2026-05-06 23:34:29 -07:00
Patrick Buckley 779ec638a5 fix(session): byte-clamp REMINDER_TEXT_STORAGE_CAP + drop local-only doc citation
Closes round-2 review findings bug-1 (minor) and q-2 (minor).

* **bug-1:** ``_encode_reminders`` clamped each entry's ``text`` field
  with Python ``str`` slicing, which counts codepoints.  Multi-byte
  UTF-8 input (CJK, emoji) could land 4 bytes per character past the
  cap, defeating the row-width / FTS5-index protection by up to 4x.
  Switch to UTF-8 byte clamping with ``errors="ignore"`` on the
  decode boundary so a slice mid-codepoint drops the partial
  character cleanly.

* **q-2:** Both the constant block-comment and the ``_encode_reminders``
  docstring referenced ``docs/design/watch-card-ux-briefing.md`` —
  local-only per project convention (``feedback_no_design_doc_commits``)
  so the canonical repo reads as a dead reference.  The cap value
  stands by itself; the row-width / FTS5 WHY is enough.
2026-05-06 23:34:29 -07:00
Patrick Buckley 7e35050b68 fix(metacog): cleanup batch — share watch-key constant, sanitize metadata, drop tombstones
Closes round-1 review findings q-2 (minor), q-5 (minor), q-6 (nit), q-7
(nit), sec-1 (nit), perf-4 (nit).

* **q-5:** Export ``_WATCH_REMINDER_OPTIONAL_KEYS`` from
  ``turnstone/core/watch.py`` and import in the dispatch closure
  (session.py) and the replay filter (server.py:_build_history).  The
  three-place duplication of the literal tuple
  ``("watch_name", "command", "poll_count", "max_polls", "is_final")``
  is gone; future field adds touch one constant.

* **sec-1:** Run ``sanitize_payload`` over string-typed metadata fields
  (``watch_name`` / ``command``) before they enter the queue.  Today's
  consumers all use ``textContent``, but the asymmetry — sanitised
  ``text`` alongside unsanitised metadata — would survive forever in
  DB rows and resurface if a future consumer used a non-textContent
  sink (aria-label, copy-to-clipboard, markdown render).

* **q-7:** Drop the per-iteration ``isinstance(reminder, dict)`` from
  the dispatch closure's metadata comprehension.  By the time the
  block runs, ``text = reminder.get("text", "") if isinstance(...)``
  + the ``if not sanitized: return`` guard above already established
  ``reminder`` is a non-empty dict.

* **q-2:** Strip tombstone-style references — "post-#482", "post-#484",
  "Step 7 of the watch-card UX plan", "Post-Step-7 dispatch surface",
  and the brittle line-anchor "session.py:2685-2686" — across
  ``session.py``, ``test_session.py``, ``test_watch.py``,
  ``test_watch_dispatch.py``, ``test_watch_integration.py``.  Comment
  intent preserved; historical anchors gone.

* **q-6:** Drop the ``del source`` line in ``cli.py``'s
  ``on_user_reminder``; the parallel ``on_tool_reminder`` ignores
  ``tool_call_id`` without ``del`` and the comment alone is enough.

* **perf-4:** Document the SQLite ``render_as_batch=True`` recreate
  cost in migration 050's docstring — first deployment after upgrade
  copies the conversations table twice (one per ``add_column``).
  PostgreSQL is unaffected.

5734 non-live tests pass; ruff + mypy clean.
2026-05-06 23:34:29 -07:00
Patrick Buckley 869135d97a fix(ui): wrap interactive reminder spans in .msg-body + exclude system-nudge from anchor lookup
Closes round-1 review findings q-3 + q-4 (minor, merged) and bug-3 + bug-4
(nit, merged).

* **q-3 + q-4:** The new ``.msg.user-reminder .msg-body { white-space:
  pre-wrap }`` rule was a no-op on the interactive UI because that
  frontend's ``_buildDefaultReminderBubble`` appended label + text spans
  directly to the outer ``.msg.user-reminder`` element with no
  ``.msg-body`` wrapper.  Coord rendered the same shape with a wrapper.
  The two implementations diverging on DOM structure also meant a
  shared-helper extraction was harder than necessary.  Reconciled by
  wrapping interactive's spans in ``.msg-body`` to match coord; the CSS
  rule now applies to both UIs and the shared-extraction follow-up to
  ``shared_static/cards.js`` is mechanical (deferred per the review
  report — out of scope for this commit).

* **bug-3 + bug-4:** The reminder anchor lookup ``.msg.user`` also
  matched ``.msg.user.system-nudge`` markers because the marker carries
  both classes.  A non-wake reminder fired between a wake marker and
  the next real user message would anchor below the wake marker rather
  than the previous real user message.  Edge case (``/history`` reload
  corrects), but the fix is mechanical: change the selector to
  ``.msg.user:not(.system-nudge)`` in both files.
2026-05-06 23:34:29 -07:00
Patrick Buckley 885f6a9185 fix(memory): wire limit kwarg through load_messages
Closes round-1 review finding perf-2 (minor).

Storage backends accept ``*, limit: int | None = None`` (see
:meth:`StorageBackend.load_messages` at storage/_protocol.py:146) but
the in-memory wrapper at memory.py:82-85 dropped the kwarg, so
callers that wanted to tail-load (e.g. ``session.resume`` against a
long-running coord with hundreds of wake rows + persisted reminder
JSON) were forced to pull every row through the wrapper anyway.

Wraparound is mechanical: signature widens, default leaves existing
callers unaffected.
2026-05-06 23:34:29 -07:00
Patrick Buckley 81502c962f fix(session): delete stale 'reminders stay in-memory' comment
Closes round-1 review finding q-1 (major).

The comment block above ``self._attach_pending_user_reminders(user_msg)``
asserted that reminders "stay in-memory only and don't persist across
reloads" — directly contradicted by the comment block immediately below
(at the save_message call site) that explains the new persistence
semantics, plus the actual code that now writes ``_source`` and
``_reminders`` to the conversations row.  Future readers hitting both
blocks would lose trust in the surrounding comments.

The lower block already documents the persistence contract, so the
upper block is just deleted rather than rewritten.
2026-05-06 23:34:29 -07:00
Patrick Buckley 91e7f2daca fix(session): preserve _source/_reminders on fork + cap persisted reminder text
Closes round-1 review findings bug-2 (major), perf-1 (minor), perf-6 (nit).

* **bug-2:** ``ChatSession.resume(..., fork=True)``'s bulk-row builder
  silently dropped the ``_source`` and ``_reminders`` side-channel
  data the source workstream had persisted via ``_append_user_turn``.
  Both backends' ``save_messages_bulk`` already accept these keys
  (the columns exist post-migration 050) — the bulk builder just
  didn't supply them.  The fork's resumed transcript would then look
  like the assistant turn answered out of nowhere: every wake marker
  and every reminder bubble that survived to disk on the source got
  dropped on the fork.  New regression test
  ``test_fork_preserves_source_and_reminders`` pins the contract.

* **perf-6:** Extracts ``_encode_reminders(reminders) -> str | None``
  near ``_apply_reminders_for_provider`` so the user-turn save path,
  the tool-turn save path, and the new fork bulk builder share one
  encoder.  Eliminates the drift risk between three near-identical
  ``json.dumps(..., separators=(",", ":")) if X else None`` patterns.

* **perf-1:** The new helper clamps each entry's ``text`` field at
  ``REMINDER_TEXT_STORAGE_CAP = 8192`` characters before encoding so
  a single rogue producer (a watch streaming unbounded shell output,
  a corruption-class steering payload) can't blow the conversations
  row width or the FTS5 index.  The in-memory side-channel keeps the
  full body — only the persisted JSON is clamped.  Mirrors
  ``TOOL_RESULT_STORAGE_CAP`` on tool result rows.

5734 non-live tests pass; ruff + mypy clean.
2026-05-06 23:34:29 -07:00
Patrick Buckley f1466ca7e3 fix(session): flag persisted reminders delivered on resume
Persisted ``_reminders`` survive ``load_messages`` but the in-memory
``_reminders_delivered`` flag does not (it's session-scoped — set by
``_mark_reminders_delivered`` after each successful provider stream,
never persisted alongside the JSON column).  Without a re-splice
guard at resume time, ``_apply_reminders_for_provider`` would walk
every loaded message, see ``_reminders`` set + the flag falsy, and
splice every historical ``<system-reminder>`` envelope onto the wire
on the very next user turn — leaking each reminder a second time, the
turn after it had already advised.

Mirror the post-stream hook in ``resume()``: every loaded message
that carries reminders has already been delivered (it survived to
disk), so flag it accordingly so ``_apply_reminders_for_provider``
short-circuits on the pass-through path.

Test pins the contract end-to-end — stage a workstream with a
persisted reminder, resume into a fresh session, append a live user
turn, run the wire transform, and assert the historical reminder
body does NOT land in the rendered output.
2026-05-06 23:34:29 -07:00
Patrick Buckley 6ae6877acc feat(ui): structured watch-result card + system-nudge marker on replay
User-visible slice of the watch-card UX workstream — combines the
replay-path widening, both frontend renderers, the CSS, and the
cross-cutting Python tests.

server._build_history widens the reminder filter from {type, text} to
project on a known set of optional fields (watch_name, command,
poll_count, max_polls, is_final) and surfaces _source as
entry["source"] when set.  The known-key filter narrows the blast
radius if a future producer accidentally stuffs sensitive fields
into the dict.

SessionUIBase.on_user_reminder takes a new source: str | None kwarg
that rides on the SSE event when set.  _attach_pending_user_reminders
forwards user_msg["_source"] so non-originating tabs see the wake's
"system_nudge" tag and render the thin marker.  Protocol + cli + eval
implementations widen accordingly.

Frontend (coordinator.js + app.js — touched in lockstep per project
memory's "logic that lands in BOTH UIs must touch both files"):
* Branch on r.type === "watch_triggered" for a structured
  .msg.watch-result card with header / $ command / <pre> body /
  poll N/M [· final] footer.
* New addSystemNudgeMarker (interactive) + appendSystemNudgeMarker
  (coord) renders a thin .msg.user.system-nudge anchor for
  wake-driven reminders, both live (source === "system_nudge" on the
  SSE event) and replay (msg.source === "system_nudge").
* Default .msg.user-reminder rendering preserved for every other
  metacog nudge type.

CSS (shared_static/chat.css):
* New .msg.watch-result rules — full-width treatment, cyan accent,
  monospace body with word-break: break-word for mobile.
* New .msg.user.system-nudge rule — thin yellow marker.
* Bonus newline-collapse fix: .msg.user-reminder .msg-body now sets
  white-space: pre-wrap so multi-line shell output / bulleted lists
  stay readable inside the advisory bubble.

Plan reference: docs/design/watch-card-ux.md §4 Steps 9-12 + bonus
CSS §11 (Commit 4).
2026-05-06 23:34:29 -07:00
Patrick Buckley 13db19905a feat(metacog): structured watch reminders carry watch metadata onto NudgeQueue
WatchRunner._dispatch_result now takes a structured reminder dict
produced by build_watch_reminder() — text matches format_watch_message
verbatim (so compaction / channel adapters / wire splice keep their
behaviour), and watch_name / command / poll_count / max_polls /
is_final ride alongside as queue-entry metadata.

The dispatch closure registered in ChatSession.set_watch_runner pulls
the optional fields out of the dict and passes them to enqueue via
the new metadata kwarg.  Drain seams already merge metadata into the
rendered reminder dict (Commit 2), so the SSE event for a watch fire
now carries the structured fields without further plumbing.

* turnstone/core/watch.py — new build_watch_reminder() helper, _poll_watch
  switches from format_watch_message + dispatch(str) to build_watch_reminder
  + dispatch(dict).  set_dispatch_fn / get_dispatch_fn / restore_fn
  signatures widen from Callable[[str, str], None] to
  Callable[[dict[str, Any], str], None].
* turnstone/core/session.py — dispatch closure builds the metadata dict
  via {k: reminder[k] for k in ("watch_name", "command", ...) if k in reminder}
  and passes it to nudge_queue.enqueue.
* tests/test_watch.py — new TestBuildWatchReminder class pinning the
  builder shape; existing dispatch_fn_registry / restore_fn tests
  updated to dict shape.
* tests/test_watch_dispatch.py — every dispatch(...) call updated to
  pass a structured reminder dict via _reminder() helper; new
  TestMetadataPropagation class pins the metadata-on-enqueue contract.
* tests/test_watch_integration.py — _dispatch_result calls updated to
  dict shape.

Plan reference: docs/design/watch-card-ux.md §4 Step 7 + Step 8 watch-test
subset (Commit 3).
2026-05-06 23:34:29 -07:00
Patrick Buckley 30b7e4dd24 refactor(metacog): widen NudgeQueue._Entry with optional metadata field
Producers (today only watch_triggered) can now attach a metadata dict
to a queued nudge so the rendered reminder dict on the user/tool side
carries fields beyond {type, text}.  Wire shape stays additive: the
SSE event picks up the optional fields when present, and producers
without metadata leave it None.

* _Entry grows from 4 fields to 5 — metadata: dict[str, Any] | None.
* enqueue accepts metadata=... as a kwarg.
* drain returns list[tuple[str, str, dict | None]] (was 2-tuples).
* pending stays narrow at (type, text) for legacy callers; new
  pending_with_metadata projects the third slot for tests that need
  to assert producer-specific fields.
* Three drain consumers in session.py — _collect_advisories,
  _attach_pending_user_reminders, deliver_wake_nudge_from_queue —
  unpack the new 3-tuple shape and merge metadata into each
  reminder dict.
* on_user_reminder / on_tool_reminder protocol signatures widen
  from list[dict[str, str]] to list[dict[str, Any]] across
  ChatSession.UI, SessionUIBase, CLI, eval harness.

Plan reference: docs/design/watch-card-ux.md §4 Step 6 + Step 8 _Entry
subset (Commit 2).
2026-05-06 23:34:29 -07:00
Patrick Buckley f64c3e7b10 feat(storage): persist _source + _reminders side-channels on conversations
Adds two TEXT-NULL columns to the conversations table so multi-tab /
multi-device replay sees the same metacognitive bubble shape the
originating tab saw live.  Until now, reminders lived only on the
in-memory ChatSession.messages dict, and the wake-driven empty user
turn was not persisted at all (skip at session.py:2685-2686) — a
second tab connecting via /history saw the assistant turn with no
preceding wake context, and missed every other tab's reminder
bubbles besides.

Single Alembic revision 050 (head was 049) adds:
  * conversations._source — today only "system_nudge" for wake rows
  * conversations._reminders — JSON-encoded reminder list

Both backends (sqlite + postgresql) thread the columns through
save_message / save_messages_bulk / load_messages.  reconstruct_messages
unpacks the row tuple as 9 elements (was 7), JSON-decoding _reminders
on the user AND tool branches with the same contextlib.suppress guard
the existing provider_data / tool_calls decode uses.  Tool-row
reminders ride the same column so tool_error / repeat replay shape
matches user-channel parity.

session.py:2685-2686 wake-row persist skip is dropped; _append_user_turn
JSON-encodes user_msg["_reminders"] and passes both source + reminders
to save_message.  The tool-message save site at session.py:3014-3020
mirrors with metacog_reminders.

Plan reference: docs/design/watch-card-ux.md §4 Steps 1-5 (Commit 1).
2026-05-06 23:34:29 -07:00
Patrick Buckley c6b4dc26be fix(console): atomic coord-subsystem commit + offload startup teardown
Address Copilot review feedback on PR #487:

1. **Atomic commit invariant**: ``_bootstrap_coord_subsystem`` previously
   stamped ``coord_mgr`` ~50 lines before the final ``coord_registry``
   commit, and started threads + subscriptions in between.  A concurrent
   dashboard request running through ``_require_coord_mgr`` during the
   runtime-bootstrap window could observe ``coord_mgr`` set with
   ``coord_registry`` still ``None`` and surface the misleading
   "Restart the console after adding a model definition" 503.

   Refactored to two phases: (a) build everything as locals, (b) start
   side-effects (StateWriter / observer / nudge watcher / child fan-out
   / cleanup thread), then atomic commit at the end with ``coord_mgr``
   stamped LAST.  The build-phase ``try/except`` rolls back any started
   side-effects from local handles before re-raising — no daemon thread
   or subscription leaks across retries, and ``app.state`` is never
   stamped on a partial failure.

2. **Class-attr cleanup symmetry**: ``_teardown_partial_coord_subsystem``
   now also clears ``ConsoleCoordinatorUI._coord_mgr`` /
   ``_collector`` / ``_console_metrics`` to match the lifespan shutdown
   path (server.py ~line 4629).  A failed bootstrap (or test teardown
   reuse) no longer leaks process-global pointers at a half-built
   subsystem.

3. **Lifespan startup offload**: the lifespan startup error path used
   to call ``_teardown_partial_coord_subsystem`` synchronously, which
   in turn calls ``StateWriter.shutdown(timeout=2.0)`` — a thread-join
   + sync DB writes that could block the event loop for up to 2s
   while the console is still coming up.  Wrapped the whole
   load-and-bootstrap in ``asyncio.to_thread`` via the new
   ``_load_and_bootstrap_coord_subsystem`` synchronous helper, so all
   blocking work (including any rollback) runs on a worker thread.
   Mirrors the pattern the regular lifespan shutdown (line ~4620) and
   the runtime CRUD-triggered path already use.

Tests:
- ``test_bootstrap_atomic_commit_no_partial_visibility``: a polling
  thread in tight loop watches ``coord_mgr`` / ``coord_registry``
  during a real bootstrap and asserts no observation has ``coord_mgr``
  set with ``coord_registry`` still ``None``.
- ``test_real_bootstrap_rolls_back_partial_state_on_side_effect_failure``:
  monkeypatches ``install_idle_nudge_watcher`` to raise mid-build,
  asserts ``app.state`` shows the clean fresh-install state and the
  builder-failure error string surfaces ``RuntimeError`` (not the
  stale "no models" boot-time message).
2026-05-06 23:29:08 -07:00
Patrick Buckley 3143965e00 fix(console): bootstrap coord subsystem on first model add
A freshly-installed console with no model rows in the DB at boot
caught the ``ValueError`` from ``load_model_registry()`` in the
lifespan and skipped the entire coord subsystem build, leaving
``coord_mgr`` ``None``.  ``_refresh_coord_registry`` then bailed
out at ``existing is None`` rather than building the subsystem on
first model add — operators had to restart the console after
configuring their first model in the admin panel for the
"Coordinator subsystem not initialized" banner to clear.

Extract the lifespan's coord build into a reusable
``_bootstrap_coord_subsystem`` and add ``_maybe_bootstrap_coord_subsystem``
that runs as an ``asyncio.to_thread`` follow-on after every admin
model-CRUD endpoint (create/update/delete/reload).  The helper:

- fast-paths to a no-op when ``coord_mgr`` is already set;
- guards concurrent first-install attempts with
  ``_COORD_BOOTSTRAP_LOCK`` + double-checked re-test inside the lock;
- pre-computes config-derived integers BEFORE any thread starts so
  ``int(config_store.get(...))`` failures don't strand a started
  ``StateWriter`` daemon;
- stamps ``coord_state_writer`` to ``app.state`` immediately after
  ``.start()`` so the new ``_teardown_partial_coord_subsystem`` can
  shut it down on a partial failure (no thread leaks across retries);
- atomically commits ``coord_registry`` + clears
  ``coord_registry_error`` as the final step so callers can rely on
  the invariant ``coord_registry`` is set iff ``coord_mgr`` is set;
- replaces the stale boot-time "no model definitions" message with
  a builder-failure-specific diagnosis (carrying ``type(exc).__name__``)
  on construction failure so the dashboard's 503 banner reflects the
  actual cause.

Both the lifespan path and the runtime-bootstrap path now route
through the same helper and the same teardown on failure.

Tests: 12 new tests covering the helper-level wiring (idempotent
fast-path, missing-prereq parametrised over ``config_store`` /
``collector`` / ``console_metrics``, no-rows error recording, builder
failure error replacement, partial-state teardown), the endpoint
integration, the deterministic concurrent-call lock test (uses an
instrumented lock wrapper that signals when a second acquirer arrives,
so the test fails fast on slow CI rather than depending on a
wall-clock sleep), and a real-builder end-to-end case constructing a
working ``SessionManager`` against a real ``ConfigStore`` + real
``ClusterCollector``.
2026-05-06 23:29:08 -07:00
Patrick Buckley 12cc052bca fix(mcp): apply Phase 7b PR #485 review feedback
Two of five Copilot comments on PR #485 were valid; this commit applies
both. The other three (one duplicate of comment 1, plus the INFO-logging
and `_pending`-naming nits) get rationale on-thread and resolution.

1. emit_oauth_failure_audit action now derived from `code` (#485 bug-1)

The Phase 7b refactor generalized `emit_insufficient_scope_audit` →
`emit_oauth_failure_audit`, routing both `mcp_insufficient_scope` AND
generic-403 (`mcp_*_forbidden`) through the same helper. The audit
`action` field stayed hardcoded as
`"mcp_server.oauth.insufficient_scope_emitted"`, mislabeling generic
forbidden events under the insufficient_scope bucket — downstream
alerting / analytics filtering on `action` would silently fold both
categories together.

The action is now selected from `code`:
  * `mcp_insufficient_scope` →
    `mcp_server.oauth.insufficient_scope_emitted` (preserves existing
    alerting consumers)
  * `mcp_tool_call_forbidden` / `mcp_resource_read_forbidden` /
    `mcp_prompt_get_forbidden` →
    `mcp_server.oauth.forbidden_emitted` (new, distinct label)

Detail row continues to carry both `code` and `kind` so operators get
sub-bucket distinction within either action.

2. Resource-listener docstrings cite RFC §3.2 (#485 doc-1)

Per the codebase convention established in Phase 7b round-1 q-1
(`_rebuild_user_prompt_map` corrected §3.2 → §3.3 because prompts are
§3.3 in the MCP spec), resource-related docstrings should cite §3.2.
The three resource-listener docstrings were citing §3.3, and the
"Mirrors `_notify_listeners` for tools (RFC §3.3)" parenthetical in
both `_notify_resource_listeners` and `_notify_prompt_listeners` read
as "tools are at §3.3" — confusing twice over. All four sites now
carry the correct catalog-kind citation explicitly:
  * resource-listener docstrings → "RFC §3.2 (resources)"
  * prompt-listener docstrings → "RFC §3.3 (prompts)"

Tests / lint:
  * 119 passed on 3.13 + 3.11 (targeted MCP OAuth pool tests)
  * ruff + mypy clean on both files
2026-05-06 22:09:47 -07:00
Patrick Buckley 124615cce0 feat(mcp): per-user resource + prompt pool dispatch (Phase 7b)
Extends the Phase 7 per-(user, server) ClientSession pool to cover
RFC §3.2 (resources/read) and §3.3 (prompts/get) on the same shape
already proven for tools/call. Pool discovery is capability-gated so
servers without resources/ or prompts/ stay free of extra round-trips.

API additions / widenings (MCPClientManager):
- ``read_resource_sync(uri, *, user_id=None, timeout=120)`` —
  per-user-first dispatch; falls through to the byte-identical static
  path when ``user_id`` is None or the URI doesn't resolve to an
  ``oauth_user`` pool entry.
- ``get_prompt_sync(prefixed_name, arguments=None, *, user_id=None,
  timeout=30)`` — same dispatch shape; structured-error responses
  surface via ``RuntimeError`` so the agent-loop's ``except Exception``
  block renders the JSON without polluting the prompt-protocol return
  shape.
- ``get_resources(user_id=None)`` / ``get_prompts(user_id=None)`` —
  per-user merged catalogs (admin/global call still passes None).
- ``add_{resource,prompt}_listener`` /
  ``remove_{resource,prompt}_listener`` —  ``user_id`` keyword scopes
  the listener so a pool-only catalog change for one user does not
  wake another user's session.
- ``resource_count_for_user(user_id=None)`` /
  ``prompt_count_for_user(user_id=None)`` — method-form variants used
  by ChatSession's ``read_resource`` / ``use_prompt`` tool gating; the
  legacy ``resource_count`` / ``prompt_count`` properties remain
  static-only for admin paths.
- ``_dispatch_pool_resource`` / ``_dispatch_pool_prompt`` async coros
  — mirror ``_dispatch_pool`` for the new SDK calls; share the
  carrier-race-and-cancel core via ``_dispatch_pool_with_entry_call``.
- ``_handle_auth_403`` extended with ``kind=Literal["tool",
  "resource", "prompt"]`` so the per-operation ``mcp_*_forbidden``
  code surfaces (kind="tool" remains the default for back-compat).
- Pool notification handler now refreshes resources / prompts on
  ``ResourceListChangedNotification`` / ``PromptListChangedNotification``
  via ``_refresh_pool_server_resources`` / ``_refresh_pool_server_prompts``.

ChatSession (``turnstone/core/session.py``) call-site updates:
- 12 sites threaded the session-bound ``user_id`` through
  ``add_*_listener`` / ``remove_*_listener``, ``get_resources`` /
  ``get_prompts``, gating, ``read_resource_sync`` /
  ``get_prompt_sync``, and ``is_mcp_prompt`` so the per-user merged
  catalog drives both the visible-tool set and dispatch.
- ``/mcp`` slash command now lists this user's pool resources and
  prompts alongside tools (Phase 7 already scoped tools).

Scope decisions:
- Per-user-first URI ordering (decision 0.1): the dispatcher attempts
  the user's pool catalog first, falling back to the static catalog
  only when no pool entry resolves the URI / prefixed name. Pool-only
  users never see the static catalog leak into their resolution.
- Method-form ``*_count_for_user`` (vs property) keeps the legacy
  ``resource_count`` / ``prompt_count`` properties intact for admin
  endpoints whose contract is "static catalog size only".
- Shared ``_dispatch_pool_with_entry_call`` helper accepts an
  ``sdk_call: Callable[[ClientSession], Awaitable[Any]]`` closure,
  keeping the entry-locked carrier-race / classification / retry
  plumbing single-source instead of a 3x copy across tool / resource
  / prompt paths.

R6 (anyio uniformity): every pool-side list / read / get path uses
``async with asyncio.timeout(...)`` — ``asyncio.wait_for`` is
forbidden in those paths because it wraps the inner awaitable in a
fresh task and surfaces ``CancelledError`` from inside
``streamablehttp_client``'s anyio TaskGroup on Python 3.11
(per ``feedback_asyncio_timeout_vs_wait_for.md``).

Tests:
- ``test_mcp_pool_auth_resource_integration.py`` — 9 real-transport
  resource tests (FastMCP upstream + ``BehaviorMiddleware``):
  401-refresh-retry success, persistent 401 -> consent_required,
  403+insufficient_scope, 403 generic -> mcp_resource_read_forbidden,
  breaker-isolation under repeated auth failures, missing-token,
  decrypt-failure, http:// URL guard, unknown-URI ValueError.
- ``test_mcp_pool_auth_prompt_integration.py`` — 9 mirror tests for
  the prompt path; structured-error responses verified via
  ``RuntimeError`` payload shape.
- ``test_mcp_user_catalog.py`` — extended unit coverage for per-user
  resource / prompt rebuild + collision policy + symmetric eviction.
- ``test_sessions.py::TestMCPToolGating`` — pool-only-user canary
  asserts ``read_resource`` / ``use_prompt`` stay visible when the
  static catalog is empty but the user has pool entries.

Round-1 review fixes (4-finder review applied, no push yet):
- bug-1: ``_exec_use_prompt`` was hardcoding ``"MCP prompt error: failed
  to invoke prompt"`` — discarding the structured-error JSON that
  ``_dispatch_pool_prompt_sync`` raises via ``RuntimeError``. Now uses
  ``f"MCP prompt error: {e}"`` mirroring ``_exec_mcp_tool``; pool-prompt
  consent_required / insufficient_scope / forbidden errors now reach
  the LLM as intended.
- bug-2 + bug-3: resource template discovery was uncapped —
  ``_cap_server_resources`` covered ``res_result.resources`` but the
  separate ``tmpl_result.resourceTemplates`` loop appended every
  template a server returned. Added ``_MAX_RESOURCE_TEMPLATES_PER_SERVER``
  (1000) + ``_cap_server_resource_templates`` helper, applied at both
  the initial discovery site (``_connect_one_pool``) and the refresh
  site (``_refresh_pool_server_resources``). Mirrors the existing
  ``_MAX_TOOLS_PER_SERVER`` / ``_MAX_PROMPTS_PER_SERVER`` defensive
  ceilings.
- sec-1 + sec-2: ``emit_insufficient_scope_audit`` generalized to
  ``emit_oauth_failure_audit(kind, code, ...)``, called from both the
  insufficient_scope branch AND the previously-silent generic 403
  branch. Audit detail now records ``{"kind": kind, "code": code,
  "scopes_required": [...]}`` so operators can distinguish tool-call
  vs resource-read vs prompt-get 403s in audit logs and so cross-
  tenant probing on the generic 403 path leaves a trail. The Phase 7
  inherited gap (``mcp_tool_call_forbidden`` had the same silence) is
  closed in the same refactor.
- perf-1: pool resource discovery now uses ``asyncio.gather(
  list_resources, list_resource_templates)`` inside the existing
  ``async with asyncio.timeout(...)`` budget — disjoint catalogs, no
  ordering dependency. Typical-case 2-RTT cold-connect resource block
  collapses to 1-RTT. Same change applied at ``_refresh_pool_server_resources``.
- q-1: ``_rebuild_user_prompt_map`` docstring corrected RFC §3.2 →
  §3.3 (resources are §3.2; prompts are §3.3).
- q-2: ``_refresh_pool_server_prompts`` docstring now carries the
  R6 / mcp-loop note that the resource sibling already had — both
  refresh paths now declare the asyncio.timeout invariant explicitly.
- q-5: added the ``_user_resource_map`` / DB-mismatch guard to
  ``read_resource_sync`` for parity with ``get_prompt_sync``. A stale
  per-user map entry with no matching oauth_user row now raises a
  specific ValueError instead of silently falling through to a
  generic ``Unknown MCP resource``.
- q-6: ``_dispatch_pool_with_entry`` (now a single-caller wrapper
  after the ``_dispatch_pool_with_entry_call`` extraction) gains a
  one-line docstring explaining why the wrapper is preserved
  (tool-decode localization + stack-trace identity for debugging).
- q-7: added 1 resource + 1 prompt end-to-end integration test that
  drive REAL discovery + dispatch in the same connect (no
  ``_seed_pool_*_map`` shortcuts), mirroring the tool path's
  ``test_integration_pool_reuse_401_refresh_and_retry_succeeds``.
  The seeded-map tests stay (faster, focused on dispatch); the new
  e2e tests cover the connect-discover-dispatch composition that
  caught Phase 6's carrier-on-entry bug.

Pre-push round-1 review fixes (3-finder review on the final state —
the lesson from Phase 7 round-3's q-1 regression: round-2 catches
what the round-1 apply pass missed):
- q-1 (MAJOR): the bug-1 sibling that round-1 missed —
  ``_exec_read_resource`` was hardcoding ``"MCP resource error: failed
  to read resource"`` while ``_exec_use_prompt`` (post-bug-1) preserved
  the structured-error JSON via ``f"... error: {e}"``. The round-1
  apply pass patched the prompt side but not the resource side. q-5's
  per-user-map / DB-mismatch ValueError was being swallowed at the
  agent loop boundary, defeating the operator-diagnostic intent. Now
  ``_exec_read_resource`` mirrors ``_exec_mcp_tool`` and ``_exec_use_prompt``.
- q-6 (nit): defensive-cap comment block at module-level cited
  "(RFC §3.2)" while covering both resource and prompt list paths;
  prompts are §3.3. Now reads "(RFC §3.2 for resources, §3.3 for
  prompts)" matching the convention the q-1 apply established.
- q-5 (rejected with better justification): the reviewer flagged
  ``_dispatch_pool_with_entry`` as a single-caller wrapper that should
  be inlined. After examination — the autouse fixture
  ``tests/test_mcp_pool_auth_introspection.py::_install_capture_intercept``
  monkeypatches this method to stash ``entry.auth_capture`` for the
  fake call_tool stubs in dispatcher-asserting tests. Inlining would
  redirect the patch to ``_dispatch_pool_with_entry_call`` (different
  kwargs shape) and require re-validating every test that depends on
  the interception. The wrapper IS load-bearing; q-6 docstring updated
  to cite the test-fixture rationale instead of the thin "stack-trace
  identity" claim.

Deferred to follow-up (documented rationale):
- perf-2: single-pass partition for system-message resource list
  (concrete vs templates). Sub-microsecond at expected scale;
  opportunistic-only.
- q-2 (pre-push): ~200 lines of fixture infrastructure
  (``BehaviorMiddleware``, ``_build_server``, ``_seed_oauth_server``,
  ``running_loop_mgr``, etc.) duplicated across three pool-integration
  test files. Real maintenance cost, but a 200-line conftest extraction
  is a focused refactor that earns its own commit / PR. Tracking as
  follow-up rather than balloon Phase 7b's diff further.
- q-3 / q-4 (refactor): extract shared dispatcher / scheduler
  helpers to compress three near-identical 90-line bodies (round-1
  q-3 was the same root cause; the pre-push q-3/q-4 reviewer
  reaffirmed it concretely). Three named methods preserve readability
  for the codebase's hottest correctness path; follow-up if
  duplication grows further or if a per-path divergence ships.
- q-4 (round-1, distinct from pre-push q-4): split pool concerns
  into ``mcp_pool.py``. Out-of-scope per finder; future refactor as
  the file approaches the navigation/merge-conflict threshold.

3.13: 5590 passed (5541 baseline -> +49 net; pre-review +47, q-7
e2e tests added +2). Existing audit-detail tests updated in-place
to expect the new ``kind`` and ``code`` fields.
3.11: 5590 passed (parity gate per ``feedback_pytest_env_parity.md``).
2026-05-06 22:09:47 -07:00
Patrick Buckley c757c22f55 fix(metacog): atomic cap-and-drop helper for soft-cap producers
Closes PR #484 review findings (Copilot): the soft-cap pattern in
``ChatSession.set_watch_runner``'s dispatch closure was a non-atomic
two-call pair (``count_by_type`` then ``drop_oldest_by_type``) with
two separate lock acquisitions.  A concurrent drain on the worker
thread (``USER_DRAIN`` / ``TOOL_DRAIN`` consuming ``"watch_triggered"``
entries via the ``"any"`` channel) could slip between the two calls,
making the drop a no-op.  The dispatch closure also discarded
``drop_oldest_by_type``'s return value and unconditionally logged
``dropped_oldest=True``, so a no-op drop got reported as a successful
drop.

* New ``NudgeQueue.cap_at_or_drop_oldest(nudge_type, max_depth,
  channel=None) -> bool`` does the count+drop in a single critical
  section.  Returns the actual outcome.

* Dispatch closure (``session.py:1410-1416``) now calls the helper and
  uses its return value to gate the WARNING log line, so the log is
  accurate when a drop did NOT happen.

* ``drop_oldest_by_type``'s docstring no longer overstates the
  per-call lock as covering a count+drop pair — it points readers
  to ``cap_at_or_drop_oldest`` for that contract.

7 new tests in ``TestCapAtOrDropOldest`` cover: below-cap no-op,
at-cap drop-oldest, above-cap drop-only-one (per-call), channel
filter, other-type isolation, ``max_depth <= 0`` defensive no-op,
no-match.

5708 non-live tests pass; ruff + mypy clean.

The github-code-quality bot finding ("Statement has no effect" on
``_protocol.py:939``'s ``...`` body) is a false positive — every
Protocol method in ``_protocol.py`` uses ``...`` as its body, which
is the canonical Python Protocol pattern.  Replacing with ``pass``
would diverge from the file's existing style.  No code change.
2026-05-06 16:16:57 -07:00
Patrick Buckley 39e0f930c1 fix(metacog): factor sanitiser regex tail + trim docstrings + drop tombstone
Closes round-2 review findings q-3, q-4, q-5, q-7.

* **q-4:** ``_NAME_CONTROL_CHARS`` and ``_PAYLOAD_CONTROL_CHARS`` shared
  7 lines of Unicode-steering character classes (zero-width / bidi /
  separators / BOM / tag chars above BMP).  Factored into a single
  ``_CONTROL_CHARS_TAIL`` constant; each regex now differs only in its
  leading ASCII range.  Future bidi or zero-width additions edit one
  place.

  Side effect: this corrects a latent bug where ``_NAME_CONTROL_CHARS``
  had two literal ASCII spaces in place of U+2028 / U+2029 (line and
  paragraph separators) — visible as ``r"  "`` in source but rendered
  as the actual codepoints in ``_PAYLOAD_CONTROL_CHARS``.  After the
  factoring both regexes correctly include U+2028 / U+2029, closing
  the gap that would have let a workstream name with embedded line
  separators forge a sibling bullet (the same vector ``\n`` was
  blocked for in the original bug-1 fix).

  Switched to ``\u`` escapes for readability (and to keep future Edit
  tool runs against this block reliable).

* **q-3:** Tombstone clause "standing in for the deleted
  ``_watch_pending`` maxsize bound" survived in
  ``ChatSession.set_watch_runner``'s docstring after the apply-pass
  trim cleaned the inline soft-cap comment.  Dropped.

* **q-5:** ``test_newline_in_name_does_not_forge_extra_bullet`` carried
  five WHAT-narration comments restating what the immediately-following
  asserts already say.  Dropped — the docstring carries the security
  invariant; the assertions speak for themselves.

* **q-7:** ``patch_session_storage`` had a 14-line docstring including
  fallback-guidance and self-justification ("accumulated 7 near-duplicate
  sites").  Trimmed to a 3-line contract.
2026-05-06 16:16:57 -07:00
Patrick Buckley 751ed9c85f test(metacog): drop redundant valid_until test + tighten concurrency bound + cover is_watch_active
Closes round-2 review findings q-1, q-2, q-6.

* **q-1:** ``test_valid_until_drops_when_watch_missing`` collapsed to the
  same code path as ``test_valid_until_drops_when_watch_inactive`` after
  the apply-pass switched the predicate from ``get_watch[active]`` to
  ``is_watch_active`` (both stubbed via ``patch_session_storage(active=False)``).
  The "missing" case has no distinguishable branch at the dispatch
  layer, so dropping it removes a tautological duplicate.  The
  missing-row mapping moves to the storage layer (q-2 below) where it
  IS distinguishable.

* **q-2:** ``is_watch_active`` was a new public storage primitive with
  zero direct backend coverage — only via-session-via-stub coverage.
  New ``TestIsWatchActive`` in ``tests/test_watch_storage.py`` covers
  active row → True, inactive row → False, missing row → False.
  Pinned at the storage boundary so future backend changes fail loudly
  there instead of in the dispatch tests.

* **q-6:** Concurrency test had ``n_threads = 2`` alongside two literal
  Thread objects and a tautological ``assert len(threads) == n_threads``.
  Threads are now built from a labels tuple, so ``len(threads)`` drives
  the slack bound; the redundant assertion is gone.
2026-05-06 16:16:57 -07:00
Patrick Buckley 20c4dfaca6 fix(metacog): tighten concurrency bound + lift storage-patch helper
Closes review findings bug-4 and q-6.

bug-4 — the watch dispatch concurrency test bounded depth at
``_WATCH_QUEUE_SOFT_CAP + 2 * per_thread`` (= 250) which is
tautologically true: two threads × 100 fires can append at most 200
entries above the cap, so the bound asserted nothing more than what
``depth <= 2 * per_thread`` already says.  Tighten to
``_WATCH_QUEUE_SOFT_CAP + N_THREADS`` (= 52): the count-then-drop window
admits at most one slip per concurrent thread.

q-6 — 7 near-duplicate ``monkeypatch.setattr(session_mod, "get_storage",
lambda: _StubStorage())`` sites across ``test_watch_dispatch.py`` +
``test_watch_integration.py`` (4 different stub shapes, mostly trivial
variations on the active flag).  Lift a ``patch_session_storage``
helper into the existing ``tests/_helpers.py`` with kwargs for the
common cases (``active``, ``raise_on_is_active``), returns the call list
so call-shape assertions still work.  Tests collapse from ~10-line
inline-class blocks to one-line helper calls.
2026-05-06 16:16:57 -07:00
Patrick Buckley 28d9bb4802 fix(metacog): drop watch_id rebind + trim soft-cap inline comment
Closes review findings q-2 and q-5.

q-2 — ``bound_watch_id = watch_id`` rebind was unnecessary.  ``_dispatch``
is constructed fresh per fire (not in a loop), so ``_still_active``
closes over the function parameter directly without any
loop-variable-capture risk.  Drop the rebind.

q-5 — the inline soft-cap comment restated rationale already covered by
the ``_WATCH_QUEUE_SOFT_CAP`` block-comment at module scope and dragged
in a tombstone reference to the deleted ``_watch_pending`` path.  Trim
to one line stating only the WHY (drop-oldest because latest output is
most useful).  Leave the ``set_watch_runner`` docstring's operational
detail at lines 1356-1378 alone — trimming further risks losing the
``valid_until`` predicate semantics.
2026-05-06 16:16:57 -07:00
Patrick Buckley ed1eaee216 test(metacog): integration coverage for _watch_restore_fn closure
Closes review finding q-4.

The closure built inside ``server.py``'s ``_watch_restore_fn`` is the
new contract surface introduced by the switchover — it constructs a
fresh ChatSession, calls ``session.resume(ws_id)`` to adopt the
original ws_id, re-registers the dispatch closure via
``set_watch_runner``, and returns ``WatchRunner.get_dispatch_fn`` for
the runner to invoke directly.  No automated coverage exists today;
a future refactor (e.g. swapping ``manager.create + session.resume``
for ``manager.open``) could silently break the watch-restore pipeline.

Adds ``test_watch_dispatch_through_restore_fn_lands_on_rehydrated_session``
to ``tests/test_watch_integration.py`` — drives the full restore path:
persists a kickoff message for the original ws_id, fires
``_dispatch_result`` against a runner with no registered dispatch fn,
asserts the restore_fn ran exactly once, the rehydrated session is a
distinct object that adopted the original ws_id, and the watch payload
landed on the rehydrated session's NudgeQueue (not on the original).
2026-05-06 16:16:57 -07:00
Patrick Buckley 3b495eba15 fix(metacog): is_watch_active storage primitive for hot-path valid_until
Closes review finding perf-1.

The watch dispatch closure's ``valid_until`` predicate fires once per
watch entry at every drain seam — on the chat-loop hot path.  It only
needs the ``active`` flag, but ``storage.get_watch`` runs a full-row
``SELECT *`` and marshals the result into a dict.  At the typical drain
depth (cap-50 + a busy chat loop) that's ~50 throwaway dict allocations
per drain pass for one boolean.

Adds ``StorageProtocol.is_watch_active(watch_id) -> bool`` plus
SQLite + Postgres implementations doing a single-column
``SELECT active FROM watches WHERE watch_id = ?`` (returns False on
missing row).  ``_still_active`` in ``ChatSession.set_watch_runner``
now calls that instead of indexing into the full row.

Test stubs that mocked ``get_watch`` for the predicate are converted
to mock ``is_watch_active`` directly.  Bulk variant deferred — single-row
fix is sufficient at typical drain depths.
2026-05-06 16:16:57 -07:00
Patrick Buckley e5e6e13307 fix(metacog): NudgeQueue.count_by_type primitive + channel-aligned soft cap
Closes review findings perf-2, q-3, bug-3.

The watch dispatch closure's soft-cap pre-check materialised the whole
queue snapshot via ``pending(channel="any")`` only to throw away the
text and count the type — wasteful at typical drain depths (cap-50 +
mixed producers means a 50-tuple allocation per fire just to read a
length).  The other half of the cap pair (``drop_oldest_by_type``)
walked the *whole* queue regardless of channel, so a future producer
that enqueued ``"watch_triggered"`` on a different channel could be
dropped by the watch cap, and vice versa — silently surprising once
that producer existed.

Adds ``NudgeQueue.count_by_type(nudge_type, channel=None) -> int`` that
walks ``_items`` once under the queue lock without materialising
tuples; extends ``drop_oldest_by_type`` to take an optional ``channel``
filter so both halves can agree on the entry set being capped.  The
watch dispatch closure now passes ``channel="any"`` to both —
consistent with where the closure enqueues — so a future channel split
can't bleed across producers.

Adds ``TestCountByType`` mirroring the existing ``TestDropOldestByType``
shape, plus a ``test_drop_oldest_by_type_channel_filter`` case pinning
the new optional argument's behaviour.
2026-05-06 16:16:57 -07:00
Patrick Buckley 68a44cc7e2 fix(metacog): drop test_watch_live.py — defer R9 to operator-driven verification
Closes review finding q-1.

The live-marker scaffold in ``tests/test_watch_live.py`` couldn't actually
run as written: the ``live_client`` / ``live_model_id`` fixtures it
referenced live in ``tests/test_server_live.py`` at ``scope="module"``,
not on a shared ``conftest.py``, so the file would have ImportError'd
at collection if anyone ever tried ``pytest -m live`` against it.

Lifting the fixtures into a shared conftest is a larger refactor
than R9 justifies — the deterministic envelope-arrival contract is
already pinned end-to-end by ``test_watch_fires_then_user_send_drains_envelope``
and ``test_three_back_to_back_watch_fires_drain_into_one_turn`` in
``test_watch_integration.py`` (real ChatSession + real WatchRunner +
real chat-loop drain).  The model-quality-of-response leg is genuinely
manual; the plan doc's R9 entry is updated locally to reflect that
deferral.
2026-05-06 16:16:57 -07:00
Patrick Buckley e596650a5c fix(metacog): split sanitiser regex — strict for names, permissive for payloads
Closes review finding bug-1.

The shared ``sanitize_payload`` regex preserved TAB/LF/CR so multi-line
watch shell output kept its layout — necessary for the watch path, but a
correctness gap for the idle_children formatter, which renders the
user-controlled ``name`` field as a single bullet item.  A child name
with an embedded ``\n`` would split the bullet across two rendered rows
and let a hostile name forge a fake sibling entry in the listing.

Splits the regex in two: ``_NAME_CONTROL_CHARS`` strips TAB/LF/CR
(used by the new ``sanitize_name`` helper for single-line name fields),
``_PAYLOAD_CONTROL_CHARS`` keeps the existing permissive shape (used by
``sanitize_payload`` for multi-line watch payloads).
``format_idle_children_nudge`` now calls ``sanitize_name``.

Adds ``test_newline_in_name_does_not_forge_extra_bullet`` — feeds a
hostile name with embedded ``\n`` + bullet-shaped continuation, asserts
the rendered listing still has exactly N bullet rows for N children
(no forged sibling), and the hostile newline got flattened to an inline
space.  Adds a ``TestSanitizeName`` class mirroring the existing
``TestSanitizePayload`` shape for the new strict variant.
2026-05-06 16:16:57 -07:00
Patrick Buckley d2028aa4f7 fix(metacog): drop misleading _watch_restore_fn comment
The deleted comment claimed the closure may be registered "under the
rehydrated workstream's id, which may differ from the original ws_id we
restored against" — but ``ChatSession.resume(ws_id, fork=False)`` adopts
the parameter as the session's id at session.py:1682, so they match
exactly post-resume.  The lookup works because the ids are equal, not
because they may differ.

The accessor name ``get_dispatch_fn`` is self-explanatory; no replacement
comment is needed (per the project's "default to no comments" rule).
2026-05-06 16:16:57 -07:00
Patrick Buckley 17c62f7ef3 test(metacog): watch switchover boundary integration + live scaffold
Adds two boundary-crossing integration tests and one live-marker
scaffold for the watch switchover landed in the previous commits:

tests/test_watch_integration.py — drives a real ChatSession + real
WatchRunner end-to-end (LLM stubbed) through the unified pull-model
chat-loop drain seam.  Pins:

- test_watch_fires_then_user_send_drains_envelope: a synchronous
  WatchRunner.dispatch fire enqueues "watch_triggered" on "any";
  session.send drains the entry into the user message's _reminders
  side-channel — confirms the envelope splice path.
- test_three_back_to_back_watch_fires_drain_into_one_turn: pins the
  intentional behavioural delta from the plan section 3.4 / risk
  register R3 — N back-to-back fires now produce ONE assistant turn
  with N _reminders entries, not N successive turns.

tests/test_watch_live.py (new file, single test, marked @pytest.mark.live):
risk register R9 verification recipe — confirm a real LLM handles a
<system-reminder>-framed watch payload sensibly.  Collects under the
regular -m "not live" run; the user runs it on demand against an
Anthropic-backed config.

Implements watch-switchover plan section 5.2 (integration) and step 11
(live scaffold).
2026-05-06 16:16:57 -07:00
Patrick Buckley 7ca00b564c test(metacog): NudgeQueue-based dispatch tests for watch closure
Replaces the deleted tests/test_watch_dispatch.py with a focused
14-test suite exercising the closure that ChatSession.set_watch_runner
now constructs (per the previous commit's switchover).  Each test
pins one assertion:

- enqueue shape: ("watch_triggered", text, "any") on the per-session
  NudgeQueue; not on user / tool channels
- producer-side sanitisation strips control / bidi / zero-width chars
  and angle-bracket tag breakers; preserves TAB/LF/CR so multi-line
  shell output keeps its layout (R8); empty-after-strip → no enqueue
- soft-cap drop-oldest at _WATCH_QUEUE_SOFT_CAP with a queue_full
  WARNING log; non-watch entries on the same queue are not collateral
  damage
- valid_until predicate drops on inactive / missing / storage-raises;
  delivers when active (counter-test)
- concurrent enqueues across two threads stay bounded under the
  3-acquisition count-then-drop window

Implements watch-switchover plan section 5.1 / step 9.  No production
changes — pure test rewrite.
2026-05-06 16:16:57 -07:00
Patrick Buckley 94ed79d488 feat(metacog): switchover — watches enqueue onto NudgeQueue not _watch_pending
Replaces the bespoke _make_watch_dispatch / _watch_pending /
_dispatch_pending_watch / _MAX_WATCH_CHAIN machinery with a single
NudgeQueue.enqueue("watch_triggered", ...) call inside
ChatSession.set_watch_runner.  Watch results now drain at the same
<system-reminder> envelope seams as every other metacog nudge
(USER_DRAIN, TOOL_DRAIN, IdleNudgeWatcher IDLE wake) — no separate
worker-spawn, no recursive watch chain, no per-session queue.Queue.

The dispatch closure built inside set_watch_runner carries:
- producer-side sanitize_payload over the whole formatted message
  before enqueue, so steering-vector / control-char shell output
  can't tamper with the envelope at interpolation time
- a soft cap of 50 entries on per-session "watch_triggered" depth
  via the new NudgeQueue.drop_oldest_by_type, replacing the prior
  _watch_pending maxsize=20 + _MAX_WATCH_CHAIN=5 bounds; drop policy
  is drop-oldest (latest output most useful), logged at WARNING
- a valid_until predicate that re-checks
  storage.get_watch(watch_id)["active"] at drain time so a cancelled
  watch's last splat doesn't ride out a future wake

Behavioural delta documented in the plan section 3.4: N back-to-back
watch fires now drain into ONE assistant turn responding to all N
(via the envelope splice) instead of N separate send turns.  This is
intentional — fewer model invocations for noisy watches, and uniform
with the rest of the metacog pull-model surface introduced by #482.

Implements watch-switchover plan steps 5-8.  Server-side simplifications
let the previously-load-bearing _make_watch_dispatch (47 lines), its
session_worker.send import, and the chat-loop _dispatch_pending_watch
seam at the no-tools IDLE branch all disappear.  The obsolete
tests/test_watch_dispatch.py and the wake-tag test in test_session.py
(both pinning contracts that no longer exist) are removed; the
NudgeQueue-based replacement plus an integration test land in the
following commit.
2026-05-06 16:16:57 -07:00
Patrick Buckley 195ff985cc refactor(metacog): widen WatchRunner dispatch_fn signature to (msg, watch_id)
Widens the per-workstream dispatch fn signature from ``(message,)``
to ``(message, watch_id)``.  The runner now passes the originating
``watch_id`` through ``_dispatch_result`` so dispatch closures can
capture per-watch metadata at fire time — the upcoming switchover
needs this for the ``valid_until`` predicate that re-checks
``storage.get_watch(watch_id)["active"]`` before a stale entry rides
out a wake.

Also adds ``WatchRunner.get_dispatch_fn(ws_id)`` as the public
accessor used by the server-side restore path to retrieve the
closure that ``set_watch_runner`` constructed during workstream
rehydrate (avoiding private-attr access into ``_dispatch_fns``).

Implements watch-switchover plan step 4 plus risk register R4.
The pre-existing single-arg callers (``_make_watch_dispatch`` and
``set_watch_runner``'s ``dispatch_fn=`` fallback) get replaced
in the next commit; their mypy types are ``Any`` today so the
type mismatch isn't caught at this step.
2026-05-06 16:16:57 -07:00
Patrick Buckley 78ae7ae6b5 refactor(metacog): shared sanitize_payload + watch_triggered nudge type
Renames _sanitize_child_name to sanitize_payload and widens it to be
the shared producer-side sanitiser for both idle_children and the
incoming watch_triggered nudges.  The regex now skips TAB / LF / CR
so multi-line shell output rendered into a watch payload keeps its
line structure when sanitised as a whole formatted message — the
pre-switchover code path collapsed multi-line output to one line.

Adds the watch_triggered entry to _NUDGE_MAP alongside idle_children
so ``_NUDGE_MAP``-as-registry consumers (should_nudge gating, future
audit / UI tagging) recognise the type.  Body is empty — payload
comes from the producer (the watch dispatch closure), same shape as
idle_children.

Implements watch-switchover plan section 3.2 plus risk register R8
(TAB/LF/CR exclusion) and step 3 (_NUDGE_MAP registration).
2026-05-06 16:16:57 -07:00
Patrick Buckley 74f1958e47 feat(metacog): NudgeQueue.drop_oldest_by_type helper for soft-cap producers
Adds an atomic drop-oldest-by-type operation to NudgeQueue used by
producers that need a per-type soft cap on their own queue depth.
The watch dispatcher (next commit in this stack) is the first user:
when "watch_triggered" saturates, the dispatch closure drops its
oldest entry under the queue lock so the count snapshot and drop
can't interleave with a concurrent enqueue from the same producer.

Implements watch-switchover plan section 3.1 — the producer-side soft
cap takes the place of the deleted _watch_pending maxsize=20 bound.
Other producers (idle_children, advisories) have natural rate limiters
already, so the helper is opt-in per producer rather than a global cap
in enqueue itself.
2026-05-06 16:16:57 -07:00
Patrick Buckley 62909d402c fix(mcp): apply Phase 7 PR review feedback
Three Copilot findings on PR #483 (commit dad98c0); one rejected as a
false positive.

- mcp_client.py:1189 — pool notification handler's exception path
  used ``log.warning(..., exc_info=True)`` which serializes the
  chained ``httpx.Request.headers`` carrying ``Authorization: Bearer
  <token>`` into Sentry / faulthandler frame captures. Same threat
  model as the round-1 sec-1 dispatch-path fix, applied to a site
  the original review missed. Now logs structured fields only
  (server, user, exc type) without ``exc_info``.

- mcp_client.py:1202 — ``_connect_one_pool``'s handshake step used
  ``asyncio.wait_for(session.initialize(), ...)``, the same Python
  3.11 + anyio cross-task-cancel-scope anti-pattern that the
  Phase 7 round-3 q-1 fix removed from the discovery step (and that
  f6a3b66 originally addressed for ``_safe_close_stack``). Pre-
  existing Phase 5 code, but the same latent bug class — a 401
  during initialize() under 3.11 would surface ``RuntimeError:
  Attempted to exit cancel scope in a different task`` as the
  SDK's TaskGroup unwinds. Switched to ``async with asyncio.timeout(...)``
  matching the discovery step's pattern.

- mcp_client.py:1522 — renamed loop tuple-unpack variable
  ``_server_name`` → ``server_name`` in ``_rebuild_user_tool_map``.
  The leading underscore conventionally signals "intentionally
  unused", but the variable is read at the assignment a few lines
  below. Two other ``_server_name`` unpacks in this file (1410,
  3111) genuinely don't use the value and keep the underscore.

Rejected as false positive:
- test_mcp_user_catalog.py:58 (github-code-quality bot, "Statement
  has no effect"): ``await task`` inside ``contextlib.suppress(
  BaseException)`` is the standard pattern for cleanly draining a
  cancelled task. The bot's static analysis treats ``await`` of a
  result that's discarded as a no-op statement, but ``await`` here
  triggers cancellation propagation and waits for the task to
  finish — load-bearing in the fixture's teardown. No change.

Verified on Python 3.11 (``/tmp/venv311``) and 3.13 (``.venv``):
ruff + mypy clean, full test suite green.
2026-05-06 15:02:58 -07:00
Patrick Buckley a8b34bfe54 feat(mcp): per-user catalog scoping (Phase 7 — tools)
Light up production reachability of pool dispatch (RFC §3, invariant 8)
by widening the public catalog API to optionally take a ``user_id``:

- ``MCPClientManager.get_tools(user_id=None)`` returns the merged
  static + per-user pool view when ``user_id`` is supplied; the default
  preserves the legacy global-only contract.
- ``is_mcp_tool(name, *, user_id=None)`` extends the lookup to the
  per-user ``_user_tool_map``. Pool tools become reachable from
  ``ChatSession._prepare_tool`` only when the session-bound user_id
  flows through — flipping invariant 8 from "must hold" to "satisfied".
- Listener identity becomes ``(user_id, callback)``. Static-path
  changes fire ALL listeners (admin + every user); pool-entry
  changes fire only matching-user + admin (``None``) listeners.
  RFC §3.3.
- Pool sessions discover their tool list on first connect
  (``_connect_one_pool`` → ``await session.list_tools()``); the
  notification closure binds to ``(user_id, server_name)`` so
  push-driven ``list_changed`` updates target the correct user's
  catalog. R6 verified empirically: ``list_tools()`` 401 propagates
  through anyio TaskGroup unwinding, no hang — plain ``await`` is
  fine, no carrier-race shape needed for discovery.
- ``_evict_session`` drops ``entry.tools`` and rebuilds the user's
  index so an evicted-then-reconnected session doesn't carry
  stale catalog state.
- ``web_search.resolve_web_search_client`` refuses
  ``auth_type=oauth_user`` backends (per-node web search can't
  carry per-user tokens).

Resources / prompts pool dispatch deferred to Phase 7b — invariant 8
is satisfied by the tool path alone, and the resource/prompt path
needs sibling ``_dispatch_pool_resource_sync`` /
``_dispatch_pool_prompt_sync`` helpers each with their own
carrier-race plumbing (~400 LOC). Phase 7b will follow the patterns
established here.

CLI sessions default ``user_id=""`` and so cannot use oauth_user
MCP servers — documented limitation; users must use the web UI.

Round-1 review fixes (4-finder review applied, no push yet):
- bug-1: get_tools(user_id) was iterating _user_pool_entries from sync
  threads while the mcp-loop concurrently mutated it (RuntimeError:
  dictionary changed size during iteration). Now reads from a sibling
  _user_tools dict updated atomically by _rebuild_user_tool_map.
- bug-2: _close_pool_entry_if_idle (LRU/TTL eviction) skipped the
  catalog cleanup that _evict_session does — stale tools persisted
  in _user_tool_map and ChatSession's tool list never rebuilt. Now
  mirrors _evict_session.
- perf-1: _last_pool_notification_refresh debounce dict was never
  pruned in either eviction path. Now popped alongside the entry.
- perf-3: web_search resolver was issuing a sync SQL query per LLM
  turn to gate oauth_user backends. Now reads from the cached
  in-memory config.
- sec-1: bearer token could leak into exc_info-rendered tracebacks
  via Sentry/faulthandler. log.debug now uses structured fields,
  not exc_info.
- sec-2: tools-per-server response now capped at 1000 (defensive,
  mirrors _MAX_ERROR_LEN / _MAX_INSUFFICIENT_SCOPE_REPORTED).
- Test cleanup: dropped two listener fan-out tests duplicating
  test_mcp_client.py coverage; renamed test_pool_session_notification_handler
  to match its actual scope (_refresh_pool_server_tools); removed
  stale comments referencing /tmp/r6-spike*.py scratchpads and a
  misleading "copy-on-write" comment.

Round-2 pre-push review fixes (focused single-pass review applied):
- round2-1: bug-2's catalog-cleanup block in _close_pool_entry_if_idle
  had no integration test (exactly the failure mode flagged in
  feedback_tests_through_boundaries.md). Added
  test_close_pool_entry_if_idle_clears_catalog_and_fires_listener
  driving the LRU/TTL eviction path through real streamablehttp_client +
  MockTransport. Negative-test verified: reverting the
  _rebuild_user_tool_map / _notify_user_tool_listeners calls makes
  the new test fail.
- round2-3: documented the _oauth_user_server_names cache invariant
  in add_server_sync / remove_server_sync docstrings. Cache is
  reconcile_sync's sole owner — direct callers leave it stale, but
  _db_servers_to_config strips oauth_user rows so production paths
  are unaffected. Static→oauth_user transitions correctly leave the
  name in the cache because remove_server_sync drops the static
  connection, not the cache identity.
- round2-6: strengthened test_rebuild_user_tool_map_populates and
  test_rebuild_user_tool_map_drops_empty_user to assert on the
  _user_tools sibling cache (bug-1 fix). Without this, a future
  revert dropping the sibling write would still pass the unit
  tests because get_tools coverage lives in separate tests.

Round-3 full-stack review fixes (multi-stage review on the final
state caught what the layered apply passes missed):
- q-1 REGRESSION: pool tool-discovery used asyncio.wait_for around
  session.list_tools(), the exact pattern the f6a3b66 fix (and
  feedback_asyncio_timeout_vs_wait_for.md) put in place to avoid.
  Python 3.11's asyncio.wait_for wraps the inner coroutine in a
  fresh task → cross-task scope-exit when the SDK's anyio TaskGroup
  unwinds on a 401. Switched to `async with asyncio.timeout(...):`
  pattern used by _safe_close_stack.
- sec-2: TOCTOU in _connect_one_pool — entry.tools was published
  (via _rebuild_user_tool_map + listener fan-out) BEFORE entry.session
  was assigned. A sync-thread reader could observe a tool whose
  backing entry has session=None. Defence-in-depth — dispatch
  re-fetches its own token and lazy-reconnects on session=None — but
  reordering catches the race at the source. entry.session now
  publishes BEFORE catalog visibility.
- bug-1: _close_pool_entry_if_idle's _user_pool_locks.pop ran
  unconditionally after the try/finally, but the early-return
  branches (entry None on re-check, in_flight > 0 under lock) skip
  it via Python's return-through-finally semantics. The lock was
  never popped on those paths. Now gated behind an `evicted` flag
  set only on the success path; in_flight > 0 leaves the lock for
  the active dispatcher to reuse, entry-None races leave the lock
  for re-allocation by _ensure_pool_entry. Comment now describes
  the actual semantics, not the original promise.
- bug-2: softened the _rebuild_user_tool_map docstring's atomicity
  claim. The two-dict write is technically non-atomic across Python
  statements; in practice the window is sub-microsecond on the
  mcp-loop with no awaits between writes, and the listener fan-out
  fires AFTER both writes complete. Docstring now says "back-to-back
  on the mcp-loop" instead of "atomically alongside".
- q-3: dropped `hasattr(mcp_client, "server_auth_type")` defensive
  check in web_search.py. The method ships in this commit; the
  hasattr created a silent fallthrough that would let a future
  rename silently re-enable oauth_user backends.
- q-4: surfaced the CLI / empty-user_id limitation in a docstring
  comment at ChatSession.__init__'s self._user_id assignment. The
  note previously lived only inside is_mcp_tool's docstring — a
  future maintainer wiring CLI features against MCP pool servers
  wouldn't think to read is_mcp_tool to find the constraint.
- q-2 + q-5: deleted a tautological duplicate test in
  test_mcp_user_catalog.py whose docstring claimed to test
  ChatSession.close but never instantiated a ChatSession (the
  manager-level identity semantics are already covered by
  test_listener_identity_includes_user_id in the same file and by
  test_session_close_removes_listener_with_same_user_id in
  test_mcp_client.py which DOES drive a ChatSession). Reworded a
  misleading "fixture provides only 5s" comment to point at the
  actual `_run_on_loop(..., timeout=5)` site.
- q-6: the `self._user_id or None` collapse repeated at 8 sites
  across session.py. Cached once at __init__ as
  ``self._mcp_user_id`` (since ``_user_id`` is set once and never
  mutated); 8 call sites now read the cached value. The empty-
  string-is-CLI-sentinel invariant is documented at the assignment
  site, not re-asserted at each consumer.

Deferred to follow-up:
- sec-1: a hostile MCP server bound to user-A could craft a
  tool.name containing `__` to synthesize a prefixed-name collision
  in user-A's own catalog. Bounded impact: cross-tenant dispatch is
  prevented by the per-tenant token gate in _dispatch_pool, and
  user-B's get_tools(user_id="B") never includes user-A's pool
  entries. The fix needs policy decisions (reject vs. sanitize)
  and touches _mcp_to_openai which is shared between static and
  pool paths; better discussed in its own follow-up where the
  policy applies uniformly to static-path servers too. The threat
  model already requires user-A to have consented to a malicious
  server, who has many more dangerous vectors than tool-name
  shenanigans.

Test count delta: +31 tests (5435 → 5466, ``-m "not live"``; one
test deleted in round-3 apply per q-2):
- ``tests/test_mcp_client.py`` +20 (per-user catalog state, listener
  identity, session thread-through)
- ``tests/test_mcp_user_catalog.py`` +9 NEW (integration tests
  driving real ``streamablehttp_client`` + ``httpx.MockTransport`` per
  invariant 14: discovery on connect, user isolation, eviction +
  reconnect, LRU/TTL eviction (round2-1), R6 401-propagation
  regression, static byte-identical canonical regression; review
  passes dropped duplicate listener fan-out tests from earlier
  drafts whose coverage lived in test_mcp_client.py)
- ``tests/test_web_search.py`` +2 (oauth_user backend rejection +
  static backend acceptance regression; updated to use the new
  ``server_auth_type`` in-memory accessor)
2026-05-06 15:02:58 -07:00
Patrick Buckley 0fbf31e713 fix(metacog): bot-review fixes — watcher gate + two stale docstrings
Three confirmed findings from the PR #482 bot review pass.

* **Copilot (idle_nudge_watcher.py)**: ``IdleNudgeWatcher`` was gating
  wake dispatch on ``len(_nudge_queue) == 0`` (any channel), but
  ``deliver_wake_nudge_from_queue`` only drains ``USER_DRAIN``.  A
  ``"tool"``-channel entry queued by ``_queue_tool_advisory`` would
  pass the gate, spawn a wake daemon, and immediately no-op at the
  drain guard — repeating on every IDLE event for as long as the
  tool entry sat unconsumed.  No correctness bug (the no-op return
  prevents bad state) but a wasted thread spawn per IDLE.  Fixed by
  gating on ``has_pending(USER_DRAIN)``; tool-only queues no longer
  trigger the wake path.

* **Copilot (coordinator_idle_observer.py)**: docstring referenced
  the old module path ``turnstone.core.metacognition.IdleNudgeWatcher``;
  the class moved to ``turnstone.core.idle_nudge_watcher`` in q-3 of
  the apply-pass.

* **Copilot (nudge_queue.py)**: ``has_pending`` docstring cited
  ``ChatSession.deliver_wake_nudge_from_queue`` as its caller, but
  that method calls ``drain(USER_DRAIN)`` directly — no production
  caller used ``has_pending`` until this commit.  Updated to point
  at the now-actual caller (``IdleNudgeWatcher``).

* **github-code-quality (test_nudge_queue.py)**: false positive on
  ``test_channel_is_required`` — the no-channel ``q.enqueue("a", "1")``
  call is wrapped in ``pytest.raises(TypeError)`` to verify the
  validation contract.  No code change.

5571 non-live tests pass; ruff + mypy clean.
2026-05-06 12:02:27 -07:00
Patrick Buckley 3f106f98b2 fix(metacog): apply-pass fixes from pre-push full-stack review
Round-2 review caught 11 confirmed findings on the 3-commit metacog stack;
this commit applies them.

* **bug-1 (major)**: Wake source tag was leaking onto real user messages
  flushed during a wake send.  ``_append_user_turn`` and ``send`` now
  take an explicit ``from_wake: bool`` parameter — only the wake's
  synthesized first turn passes True, so ``_flush_queued_messages``'s
  real user input no longer inherits the audit tag.  Regression test
  pins the contract.

* **perf-1 (major)**: ``CoordinatorIdleObserver._maybe_enqueue`` was
  issuing list_workstreams + visible_memory_count storage queries
  before the cheap cooldown gate could short-circuit.  New
  ``_cooldown_allows`` read-only peek runs first; storage queries only
  fire when cooldown actually allows the nudge.

* **q-1 (major)**: Added the missing coord-side integration test that
  exercises ``CoordinatorIdleObserver`` + ``IdleNudgeWatcher`` together
  in the production install order against a real ``SessionManager``,
  protecting the subscription-order contract from silent regression.

* **perf-2/3 (minor)**: Cap check moved above ``_last_assistant_used_wait``;
  ``_fire_counts`` restructured as ``dict[str, dict[str, int]]`` keyed by
  ws_id so the leave-IDLE existence check is O(1).

* **perf-4 (minor)**: ``NudgeQueue.drain`` fast-paths the all-match
  case (the common one for chat-loop drain seams) by swapping
  ``self._items`` directly instead of allocating a fresh ``kept``
  deque + per-entry append.

* **perf-5 (minor)**: Wake's synthesized empty user turn no longer
  writes a content-empty row to the conversations table — the
  ``_source`` audit tag isn't column-backed and the side-channel
  reminder is stripped before persist, so the row would carry nothing.

* **q-3 (minor)**: Split ``IdleNudgeWatcher`` + ``install_*`` /
  ``shutdown_*`` helpers out of ``metacognition.py`` into the new
  ``turnstone/core/idle_nudge_watcher.py``; metacog stays a
  static-template module.

* **sec-1 (nit)**: Widened ``_sanitize_child_name``'s control-char
  regex to cover Unicode bidi-overrides, zero-width chars,
  line/paragraph separators, BOM, and tag chars.

* **q-4/q-5 (nits)**: Docstring referenced the wrong peek primitive
  (``has_pending`` → ``len()``); ``_last_assistant_used_wait``'s
  ``session`` parameter now typed ``ChatSession``.

5571 non-live tests pass; ruff + mypy clean.
2026-05-06 12:02:27 -07:00
Patrick Buckley 908e67fe4f feat(metacog): coord idle-children nudge — observer + valid_until predicates
Adds the first concrete consumer of the wake trigger: when a coordinator
goes IDLE while interactive children are still running, a
``CoordinatorIdleObserver`` enqueues an ``idle_children`` nudge that the
``IdleNudgeWatcher`` then dispatches as a synthetic empty-user-turn
``send``.  The model receives a system-reminder body listing the active
children (capped at 6 inline + 32 in the suggested ``wait_for_workstream``
call) and a nudge to block on them rather than reply prematurely.

Observer gates (in order): coord-only filter, skip if last assistant
turn used ``wait_for_workstream``, per-(ws, nudge_type) hard cap (3)
that resets only on non-wake leave-IDLE, active-children query,
``should_nudge`` cooldown.  Console lifespan registers the observer
BEFORE the watcher so subscriber-fire order has the observer
enqueueing first on the same IDLE event.

Adds an opt-in ``valid_until`` predicate on ``NudgeQueue.enqueue``
(R9 from the design risk register) — drain re-checks the predicate
outside the queue lock; falsy / raising drops the entry without
delivering it.  ``deliver_wake_nudge_from_queue`` now drains inline
before synthesizing the empty user turn so a stale predicate-drop
doesn't leave the wake send with empty content; ``_attach_pending_user_reminders``
consumes the pre-drained reminders via ``_wake_drained_reminders``.

The observer's ``valid_until`` uses ``count_workstreams_by_state``
(boolean check, no row fetch) instead of full ``list_workstreams``,
keeping the chat-loop user-attach path off the heavy query.

User-controlled child workstream names are sanitized
(``_sanitize_child_name``) before interpolation so a name like
``</thinking>...`` can't steer the model's reasoning channels through
the rendered body — the wire-boundary ``escape_wrapper_tags`` only
covers ``<system-reminder>`` / ``<tool_output>`` envelopes.
2026-05-06 12:02:27 -07:00
Patrick Buckley f0e7fea549 feat(metacog): wake trigger — IdleNudgeWatcher + ChatSession.deliver_wake_nudge_from_queue
Adds the third metacog channel: an out-of-band wake that converts a
workstream's IDLE transition into a synthetic empty-user-turn ``send``
when the session has any-channel nudges queued.  The ``IdleNudgeWatcher``
subscribes to ``SessionManager.subscribe_to_state``; on IDLE it dispatches
via ``session_worker.send`` with a no-op ``enqueue`` callback so a
busy-worker race silently drops without spawning a competing worker.

Wake-source-tag plumbing on ``ChatSession`` short-circuits metacog
detection on the synthetic empty input, suppresses queue producers
during the wake's own tool dispatch, and stamps ``_source = "system_nudge"``
on the synthetic user-message for audit / replay distinction.  The tag
is saved / restored across ``_dispatch_pending_watch`` so watch chains
recursing off the wake are processed as normal user turns rather than
inheriting the wake's guards.

Generic ``install_idle_nudge_watcher`` / ``shutdown_idle_nudge_watchers``
helpers wire the watcher into both the interactive and coord lifespans
via a single ``app.state`` registry so both surfaces share the same
teardown contract.

Foundation for PR 3 (CoordinatorIdleObserver + idle_children formatter)
and PR 4 (watch dispatcher switchover).
2026-05-06 12:02:27 -07:00
Patrick Buckley 94b3720916 refactor(metacog): unify advisory channels into pull-model NudgeQueue
Replaces the dual `_pending_user_advisories` / `_pending_tool_advisories`
list pair with a single channel-tagged `NudgeQueue` per session.
Producers tag entries with a channel ("user", "tool", or "any");
consumers drain by channel filter at their existing seams. Foundation
for the wake trigger (PR 2) and coordinator idle-children nudge (PR 3).

Existing nudges (start, correction, completion, denial, resume,
tool_error, repeat) keep their wire shape and drain timing — zero
behavior change. Cancel paths now `clear()` the unified queue.
2026-05-06 12:02:27 -07:00
Patrick Buckley f6a3b66ea4 fix(mcp): asyncio.timeout (not wait_for) for safe-close-stack on Python 3.11
Python 3.11's ``asyncio.wait_for`` wraps its inner coroutine in a fresh
``asyncio.Task`` via ``ensure_future``. When the inner is
``stack.aclose()`` on an ``AsyncExitStack`` containing
``streamablehttp_client(...)`` (anyio cancel scopes entered in the
calling task), the fresh task's attempt to exit those scopes raises
``RuntimeError('Attempted to exit cancel scope in a different task
than it was entered in')``. Python 3.12+ rewrote ``wait_for`` to use
``asyncio.timeout`` internally — runs in the current task — so 3.13
ran the same code path successfully.

Symptom on 3.11: integration tests where ``session.initialize()``
returns 4xx (e.g., 403 insufficient_scope tests) hit
``_connect_one_pool``'s ``except Exception:`` handler →
``_safe_teardown_on_connect_failure`` → ``_safe_close_stack`` → cross-
task RuntimeError. The ``concurrent.futures._base.CancelledError``
that surfaces in ``future.result(timeout=...)`` is the cascade
fallout from the asyncio loop's exception handler reacting to the
unretrieved-task-exception.

Fix: use ``asyncio.timeout`` instead of ``asyncio.wait_for`` for the
5s aclose bound. Equivalent semantics, current-task execution, works
on 3.11+. The 5s guard against ``aclose()`` hanging on a broken stack
is preserved.

Verified on Python 3.11.14 (full suite 5427 passed) and 3.13.7 (full
suite 5427 passed); all 9 integration tests pass on both.

Pre-existing bug — surfaced only after the marker fix in 5c9850c
let CI's test (3.11) actually run the 4xx tests.
2026-05-06 11:29:25 -07:00
Patrick Buckley 97086fc617 fix(mcp): pool-reuse 401 — entry-owned carrier + race-and-cancel
Two pre-existing defects in the Phase 6 pool dispatch path that only
manifest when a pooled session is reused for a second dispatch:

1. The per-dispatch _AuthCapture allocated in _dispatch_pool was wired
   into the httpx response hook only at first connect (via
   _connect_one_pool). On a reused session no fresh connect runs, so
   the hook continues writing to the original-connect's carrier while
   the new dispatch inspects an empty carrier — auth_401/403 silently
   misclassified to "other", refresh-and-retry never fires.

2. Even with the carrier on the entry (so the hook writes to a stable
   reachable object), session.call_tool itself hangs forever on
   upstream 4xx for reused sessions. Trace: SDK's spawned
   handle_request_async raises HTTPStatusError, the outer
   streamablehttp_client TaskGroup cancels post_writer, post_writer's
   finally aclose's read_stream_writer, BaseSession's _receive_loop
   exits and enters its CONNECTION_CLOSED-fanout finally. anyio's
   send_nowait skips waiting receivers with pending_cancellation; the
   dispatch task (created by run_coroutine_threadsafe for the reuse
   case) is NOT in any cancel-scope chain, so the send "delivers" but
   the receiver's Event is set on stale state — receive() never
   wakes. Test 21 doesn't hit this because its 401 happens during
   initialize, in the same task that opens streamablehttp_client, so
   the cancel scope DOES propagate.

Fix:
- Move _AuthCapture ownership to PoolEntryState (and asyncio.Event
  alongside, allocated lazily on the mcp-loop). The hook closes over
  entry.auth_capture at first connect and stays valid across
  dispatches; reset under open_lock before each call_tool.
- Race session.call_tool against the carrier's fired_event in
  _dispatch_pool_with_entry. If the event wins (hook captured 4xx
  before SDK propagated), cancel call_tool and raise an internal
  _CarrierAuthSignal — _classify_failure resolves to auth_401/403
  via the carrier's status, the dispatcher evicts the broken
  session, and the cross-task retry handshake reconnects on a fresh
  bearer.

Adds tests/test_mcp_pool_auth_integration.py::test_integration_pool_reuse_401_refresh_and_retry_succeeds
which drives the reuse path through real upstream + real SDK and is
the structural gate against this class regressing. Negative-tested
twice: revert PoolEntryState.auth_capture → test fails (carrier
empty); revert the race → test times out (SDK hang).

Also drops the @pytest.mark.asyncio decorator (replaced with
@pytest.mark.anyio) on four tests in test_mcp_pool_auth_introspection.py.
The project depends on anyio's pytest plugin (anyio is in deps);
pytest-asyncio is NOT a project dep and CI's test (3.13) failed on
those four. Local pytest happened to pick it up via system Python.

Found via Copilot review on PR #481.
2026-05-06 11:29:25 -07:00
Patrick Buckley db9260d8c4 feat(mcp): SDK 401/403 introspection via httpx response hook
Phase 6 of OAuth-MCP. Recovers upstream 401/403 from MCP servers via a
capturing httpx_client_factory: an async response hook records 4xx
status + WWW-Authenticate header into a per-dispatch carrier before
the SDK's post_writer swallows the underlying httpx.HTTPStatusError.

Splits _classify_failure into auth_401 (refresh-and-retry once) vs
auth_403 (parse insufficient_scope, emit mcp_insufficient_scope with
parsed scope set). The 401 retry runs on a fresh asyncio.Task via
run_coroutine_threadsafe in _dispatch_pool_sync, escaping the anyio
cancel-scope state of the prior dispatch's TaskGroup.

WWW-Authenticate parsing extracted to a new mcp_http_parsers module
with an RFC 7235 challenge tokenizer (replaces hand-rolled substring
scanners). Two-layer defense against multi-Bearer-challenge injection:
the hook uses get_list("www-authenticate")[0] to drop attacker's
second challenge, the parser truncates at challenge boundary as
belt-and-braces. Scope set capped at 32 entries before hitting the
audit row or the LLM-visible structured-error JSON.

Auth failures (401/403) never trip the per-server circuit breaker
(server-only breaker invariant). Static path remains byte-identical.
_PgRefreshLock untouched. Pool dispatch still reachable from the
agent loop only via Phase 7 catalog scoping; Phase 6 behaviour is
testable via direct call_tool_sync.

5557 tests pass. 33 tokenizer unit tests in tests/test_mcp_http_parsers
cover the RFC 7235 grammar + the scope/error wrappers + the 4 KB input
cap. 7 integration tests in tests/test_mcp_pool_auth_integration drive
real upstream 401/403 through streamablehttp_client + a FastMCP
subprocess fixture — the structural exit gate that makes
HTTPStatusError-injection-only unit tests insufficient.
2026-05-06 11:29:25 -07:00
Patrick Buckley 39a6b7b447 fix(man): accept canonical name(section) page notation
Models often emit page references in the standard man-page form
(``printf(3)``, ``open(2)``, ``perlfunc(3pm)``) rather than splitting
them into ``page`` + ``section`` args. The page-name sanitizer was
rejecting the parens as invalid input, killing the call. Parse the
section out of the page string before sanitization (explicit
``section`` arg still wins) and widen the section validator to accept
multi-letter suffixes like ``3pm`` / ``3perl`` that already appear on
real systems.
2026-05-05 19:47:06 -07:00
Patrick Buckley 3eb9d22ad5 fix(mcp): cancellation-safe orphan-lock drain + lock-reorder + test integrity
Phase 5 PR #479 review fix-up. Three review rounds (bot + two internal
multi-stage /review) caught:

- _PgRefreshLock now allocates a per-instance ThreadPoolExecutor instead of
  a module-global single-worker one. The global shape preserved psycopg2
  thread-affinity but serialized every advisory-lock acquire on the node
  behind one thread, even for unrelated (user, server) keys.
- get_user_access_token_classified flips to `async with lock, pg_lock:` so
  concurrent same-key callers serialize on the in-process asyncio.Lock
  before allocating the pg_lock's per-instance executor + spin loop. N
  concurrent same-key callers collapse to one executor allocation.
- _drain_orphan_pg_lock no longer re-awaits the cancelled asyncio Future
  from `__aenter__`. It receives the underlying concurrent.futures.Future
  and re-wraps it via asyncio.wrap_future, getting an independent asyncio
  Future tied to the worker outcome. This way cancellation of the awaiter
  doesn't poison the drain's wait, and the drain genuinely waits for the
  worker to settle before deciding whether to call cm.__exit__.
- Module-level _pg_refresh_drain_tasks set holds strong refs to in-flight
  drains (asyncio's task set is weak — fire-and-forget tasks could be GC'd
  mid-cleanup; RUF006 hazard).
- Drain narrows except clauses to Exception so a drain-task cancellation
  records as cancelled instead of being silently logged as 'completed
  normally with no acquire'.

Test integrity (was a major finding in round 2 — old generator-based cm
let the test pass via GC finalization timing rather than drain logic):

- New _ObservableLockCm class-based context manager whose __exit__ is a real
  observable method (records call args + thread). Distinguishable from
  GeneratorExit thrown by GC of a generator-based cm.
- Strong external ref to the cm via created_cms list — keeps cm alive past
  the test's awaits, so a no-op drain genuinely fails the assertion rather
  than papering over via GC timing.
- Deterministic drain wait via _pg_refresh_drain_tasks gather — no
  fixed-duration sleeps.
- _run_cancel_scenario helper drops the duplicated setup between the two
  cancellation tests.

Negative-test verified: replacing _drain_orphan_pg_lock body with `return`
makes test_pg_refresh_lock_cancellation_releases_on_same_thread fail with
'drain did NOT call cm.__exit__ — orphan Postgres lock + open transaction'.

Other fixes: protocol docstring corrected to describe pg_try_advisory_xact_lock
spin + retry (was claiming pg_advisory_xact_lock blocking acquire);
get_user_access_token_classified docstring rewritten for new lock order;
narrow `except BaseException` -> `except Exception` in
test_mcp_user_pool.py concurrent-dispatch helper.

882 tests pass (MCP + auth + storage). ruff + mypy clean.
2026-05-05 15:27:14 -07:00
Patrick Buckley 4db7d9c6cf feat(mcp): per-(user, server) ClientSession pool with OAuth dispatch
Phase 5 of OAuth-MCP — adds a per-(user, MCP-server) ClientSession
pool to MCPClientManager alongside the existing static-server path,
gated entirely on the per-server `auth_type='oauth_user'` config.

Pool architecture:
- `_user_pool_entries: dict[(user_id, server_name), PoolEntryState]`
  with lazy connect on first dispatch, per-key asyncio.Lock allocated
  on the mcp-loop, idle eviction coroutine (default 600s TTL, LRU cap
  200), and an `in_flight` counter as the eviction interlock so live
  calls can never be torn down mid-flight.
- `_dispatch_pool` runs the token-state machine: missing token →
  `mcp_consent_required`; key-rotation decrypt failure →
  `mcp_token_undecryptable_key_unknown` with NO consent prompt and NO
  auto-delete; expired token → silent refresh under per-(user, server)
  advisory lock; refresh failure → revoke + consent.
- `_classify_failure` separates transport (trips breaker) from auth
  401/403 (does NOT trip breaker — server-only invariant) from
  protocol (no breaker change).
- `entry.open_lock` held only across connect-or-reuse and released
  before the `await session.call_tool` so concurrent calls from one
  user against one server overlap (validated by Spike 1 scenario 2).

Auth-class failures are fail-soft in Phase 5: any 401/403 surfaced by
the SDK propagates to the agent as a tool error and the next dispatch
reconnects on a fresh refresh. Real introspection of upstream 401/403
is a Phase 6 concern — the MCP SDK's `streamable_http` post_writer
swallows `httpx.HTTPStatusError` upstream, so detecting status from
the response chain requires `McpError(CONNECTION_CLOSED)` payload
parsing or a custom httpx middleware around `streamablehttp_client`.
The mid-flight 401 refresh-retry path and the `mcp_insufficient_scope`
structured error for 403 step-up land together in Phase 6, gated by
an integration test that drives a real upstream 401/403 (the unit-
test injection of `HTTPStatusError` is what masked the production gap
on the first apply-findings pass — the integration test is the
structural gate so the gap can't reopen). RFC §1.5 steps 4-5 and the
phase table in §Implementation phases reflect this scope split.

Multi-node refresh contention:
- New `StorageBackend.acquire_advisory_lock_sync` Protocol method.
  SQLite returns nullcontext (single-node, in-process asyncio.Lock
  is sufficient). Postgres uses `pg_try_advisory_xact_lock` with
  retry on a fresh per-attempt connection, so waiters don't pin pool
  connections during the AS roundtrip. Inner try/except + nested
  finally ensures conn is always returned to the pool, even when
  begin / execute / yield / commit raises mid-body.
- Lock ordering: pg_advisory outer, asyncio.Lock inner. Re-read after
  lock collapses cluster-wide contention to one HTTP roundtrip per
  (user, server) per refresh window.
- `_PgRefreshLock` enter/exit pinned to a single-worker
  ThreadPoolExecutor so SQLAlchemy connection state stays
  thread-affine across cancellations.

Token storage refactor:
- `get_user_access_token_classified` returns a tagged TokenLookupResult
  (Token / MissingToken / DecryptFailure / RefreshFailed) so the
  dispatcher maps each state to the right user-facing error.
- `get_user_access_token` is now a thin wrapper around the classified
  variant; the previous duplicated state machine is gone.

Security:
- Pool dispatch + admin endpoints reject `http://` URLs for
  `auth_type='oauth_user'` servers (only exact loopback hostnames are
  exempt — `*.localhost` is intentionally NOT honored because RFC 6761
  localhost-zone resolution is configuration-dependent and could route
  bearers to non-loopback IPs via custom resolvers / hosts file /
  Docker overlays). Validated at three layers:
  `_dispatch_pool` (structured `mcp_oauth_url_insecure` error),
  `_connect_one_pool` (defensive ValueError), and
  `admin_create_mcp_server` / `admin_update_mcp_server` (400 before
  storage write).
- Admin URL change on an oauth_user row purges per-user OAuth tokens
  bound to the old URL: bearers are bound (via OAuth resource /
  audience) to the URL active at consent time, so silently rebinding
  them to a new URL is a token-binding violation. Re-consent forces
  fresh issuance for the new resource.
- Encryption-key fingerprints stay in audit logs only; no longer
  surfaced in agent-facing error payloads.

User_id thread-through:
- `MCPClientManager.call_tool_sync(..., user_id=None)` (additive;
  default None preserves the static path byte-identically).
- `ChatSession._exec_mcp_tool` passes `self._user_id or None`.
- `set_app_state(app_state)` setter wires OAuth state at lifespan
  startup, called from both turnstone-server and turnstone-console.

Performance:
- LRU cap eviction iterates `_user_pool_entries` (not
  `_user_pool_last_used`) so pre-dispatch entries are eligible.
- Eviction batch closes via `asyncio.gather` instead of serial await.
- `_resolve_pool_target` returns the resolved server row to
  `_dispatch_pool` to eliminate the second DB lookup.
- Production reachability of pool dispatch is gated on Phase 7
  (catalog scoping) wiring pool tools into `_tool_map`; until then
  pool dispatch is reachable only via direct `call_tool_sync` with a
  prefixed name (the path the new pool tests exercise).

Hardening parity preserved:
- Static path (auth_type ∈ {none, static}) byte-identical; PR #296
  hardening (SDK #2147 mitigations, anyio cancel-scope, stale-session-
  and-stack guard, server-only circuit breaker) intact.
- `test_reconnect_preserves_static_state_identity` unchanged + green.
- `MCPTokenStore.get_user_token` does not auto-delete on
  MCPTokenDecryptError (key-rotation safety).
- Notification debounce stays manager-level.
- Connect-failure cleanup factored into
  `_safe_teardown_on_connect_failure` shared by both connect paths.

Tests: 5475 → 5493 (+18). New file `tests/test_mcp_user_pool.py`
plus additions to test_mcp_oauth_refresh.py, test_mcp_admin_api.py,
and test_mcp_client.py covering: pool data structures, lazy connect,
eviction TTL + LRU + lock interlock, dispatch state machine (token
states), failure classification, http-rejection at dispatch and
admin layers, URL-change-purges-tokens (sec), concurrent dispatch on
one (user, server), pg_advisory lock parity, and user_id threading.

Phase exit criterion (synthetic load test 50 users × 3 servers × LRU
30 × 1000 calls × 200 evictions) deferred to a post-Phase-5 fitness
spike that runs against a staging deployment with real FDs and real
network behaviour, not a CI mock — same shape as Spike 1's
pre-Phase-0 SDK validation.

Out-of-scope for Phase 5 (Phase 6+): SDK-level 401 refresh-retry +
403 `mcp_insufficient_scope` (Phase 6), per-user catalog scoping
(Phase 7), consent UX SSE event + dashboard renderer (Phase 8),
admin UI status indicators (Phase 9).
2026-05-05 15:27:14 -07:00
Patrick Buckley e695a98c54 test(mcp): SDK 1.27 concurrency spike for per-(user, server) pool
Spike artifact validating MCP SDK behavior before Phase 5 builds the
per-(user, MCP-server) ClientSession pool. Three scenarios, all pass:

1. N=20 concurrent ClientSession instances against the same URL — no
   FD blow-up, no shared transport state, each session's tools/list
   returns independently.

2. Two concurrent tools/call on a shared ClientSession with
   interleaving payloads — request_id demux works under contention.

3. Per-session Authorization header isolation across 5 sessions —
   httpx connection pooling does not cross headers between sessions,
   so per-session bearer tokens reach the server unmixed.

Outcome gates the Phase 5 architecture (lazy dict[(user_id,
server_name), ClientSession] + per-key asyncio.Lock + LRU eviction).
Had any scenario failed, the fallback was per-call header injection
(Alternative F in the OAuth-MCP RFC).

Spike-only — not collected by pytest. Run manually:

  uv run python tests/spike_sdk_concurrency.py
2026-05-05 15:27:14 -07:00
Patrick Buckley 62bbc332af fix(mcp): pin OAuth return_url + sanitise read-scope status
Addresses ten findings on the Phase 4 OAuth-MCP commit: four from the
PR #478 review surface, plus six surfaced by a follow-up multi-stage
review of the first round of fixes. Two of the latter were genuine
security regressions in the very code that claimed to close those
holes.

Security
--------

- _validate_return_url now pins return_url same-origin against the
  configured oidc_config.redirect_base instead of request.url. Behind
  a permissive front proxy that did not normalise Host, an attacker
  could spoof Host and provide a matching absolute return_url to mint
  an open redirect off /api/mcp/oauth/start. Same fix pattern as
  PR #476 OIDC.
- Reject return_url values containing literal backslashes or starting
  with `//` up front. urlparse leaves backslashes inside `path`, so a
  value like `/\evil.example/foo` slipped through the path-only branch
  and became the protocol-relative `//evil.example/foo` after WHATWG-
  conformant browsers normalised the backslash — re-introducing the
  open redirect the same-origin pin was meant to close.
- internal_mcp_status (read-scoped) projects through a new
  _strip_server_status_for_read helper that drops the verbose `error`
  text and replaces it with a coarse `has_error` boolean. The error
  string is built as `f"{type(exc).__name__}: {exc}"` and so carries
  stdio binary paths (FileNotFoundError) or internal MCP URLs
  (httpx.ConnectError) — equivalent to leaking command/url, which
  this same patch deliberately strips. Approve-scoped refresh and
  reconnect callers continue to receive the full `error` text via
  the existing _strip_server_status helper.
- internal_mcp_status now returns the projected (sanitised) entries
  for every server in mcp_mgr.get_all_server_status() instead of
  emitting the un-sanitised dict that included `command` (stdio argv)
  and `url` (remote MCP endpoint). Sibling refresh/reconnect endpoints
  already used _public_server_status to strip these.
- internal_mcp_status docstring documents the trust boundary — server
  enumeration to read scope is intentional so dashboards can render
  per-server indicators; verbose error detail and command/url remain
  approve-scoped.

Correctness / UX
----------------

- _validate_return_url comparison normalises (scheme, host, port)
  before equality. Lowercases hostname and collapses the scheme's
  default port, so `https://App.Example.COM/x` and
  `https://app.example.com:443/x` are recognised as same-origin
  with `redirect_base = https://app.example.com` instead of being
  silently downgraded to the `/` fallback.
- mcp_crypto startup-gate error message now names both
  `mcp_token_encryption_keys` (rotation list) and
  `mcp_token_encryption_key` (single) so an operator using rotation
  isn't misled into thinking only the singular form is valid.

Cleanup
-------

- Delete the unused _KNOWN_TRUSTED_ENDPOINT_HOSTS legacy re-export
  shim in oidc.py (zero callers — a no-op that survived the Phase 4
  oauth_ssrf extraction). Sphinx :data: docstring reference at
  validate_discovered_endpoint updated to point at
  turnstone.core.oauth_ssrf.KNOWN_TRUSTED_OAUTH_ENDPOINT_HOSTS
  directly. The Google multi-origin allowlist is unaffected — it
  lives at the canonical name and is read from oauth_ssrf.py:164.
- test_mcp_oauth_handlers TestValidateReturnUrl imports
  _validate_return_url at module level instead of repeating the
  import inside each test method.
- test_server_lifespan_mcp_crypto replaces a fragile
  `messages.count("mcp_token_encryption_key") >= 2` substring trick
  with `re.search(r"mcp_token_encryption_key(?!s)", messages)` —
  asserts the singular form directly via negative lookahead.

Tests
-----

5448 pass (+13 vs the prior tip):

- TestValidateReturnUrl gains backslash-bypass, protocol-relative,
  default-port, uppercase-host, and explicit-port-mismatch cases
  alongside the original same-origin / cross-origin / scheme-
  mismatch / path-only cases.
- TestInternalMcpStatusEndpoint asserts the `error` text never
  reaches the read-scope wire (binary-path FileNotFoundError no
  longer appears anywhere in the rendered response) and that the
  coarse `has_error` boolean lights up correctly on the failed
  server.
- TestInternalMcpStatusEndpoint also pins the no-mcp-client path to
  `{"servers": {}}`.
- _routes_with_internal extended to include the
  /api/_internal/mcp-status route so the new tests can exercise it
  through TestClient.
- Existing test_startup_aborts_with_oauth_user_row_and_no_key
  strengthened to require both singular and plural key names appear
  in the error log.
2026-05-04 22:00:23 -07:00
Patrick Buckley 29c42c1427 feat(mcp): per-(user, server) OAuth 2.1 + PKCE flow
Lands the OAuth flow that uses the token-at-rest store from the prior
commit: discovery (RFC 9728 PRM + RFC 8414 AS metadata with operator-
override precedence), PKCE S256 (mandatory — refuse AS without it),
RFC 8707 resource indicator on every authorize and token request,
RFC 7591 minimal one-shot dynamic client registration, authorization-
code exchange, refresh-token grant with re-read-after-acquire single-
flight lock, and the /v1/api/mcp/oauth/{start,callback} endpoints
mounted on both server and console.

Refactored:
- validate_url_no_ssrf, validate_discovered_endpoint, is_localhost,
  effective_port, sanitize_log_text moved out of oidc.py into a shared
  oauth_ssrf module; oidc.py re-exports for compatibility. The shared
  helpers also expose async wrappers (validate_url_no_ssrf_async,
  validate_discovered_endpoint_async) so OAuth-MCP discovery — invoked
  from async handlers — does not block the event loop on the
  synchronous socket.getaddrinfo call.
- MCPTokenStore.get_oauth_client_secret reader path added (the prior
  commit was write-only)
- Storage protocol gains create/pop/cleanup_*_mcp_oauth_pending_state
  and get_mcp_oauth_client_secret_ct (mirror OIDC pending-state
  pattern: SQLite BEGIN IMMEDIATE select-then-delete, Postgres atomic
  DELETE...RETURNING)

Refresh-grant correctness:
- When the AS omits refresh_token (RFC 6749 §6 — MAY rotate), the
  existing refresh value is preserved at the OAuth-flow layer rather
  than cleared, so production ASes (Google, Auth0 default, Okta) don't
  force re-consent every hour
- expires_in accepts int, float, str-with-decimal — earlier int-coerce
  through str() failed on float and silently dropped expiry tracking
- The refresh-grant `resource=` parameter (RFC 8707) is the canonical
  MCP server URL, not the audience. Audience and resource are distinct
  concepts; using audience as resource would mismatch the AS RS
  allowlist.

Audience handling:
- _validate_token_audience accepts str or tuple; the callback resolves
  accepted_audiences = {server_url, oauth_audience} and validates
  against the set, so Auth0-style ASes that honor `audience=` (not
  RFC 8707 `resource=`) issue tokens that pass audience-bound
  validation
- build_authorize_url emits both `resource=` (RFC 8707) and
  `audience=` (Auth0-style) per server config; comment documents which
  AS implementations need which form

Security hardening:
- redirect_uri pinned to oidc_config.redirect_base instead of the
  request Host header — closes the same Host-header injection PR #476
  fixed for OIDC. Both /start and /callback return 503 with operator-
  actionable hint when redirect_base is unset
- DCR registration runs under per-server asyncio.Lock with re-fetch
  inside the lock, so concurrent /start callers don't both register
  and overwrite each other's client_id (the second user's code is no
  longer rejected on callback)
- /callback error branch pops the pending state row before redirecting
  so a leaked state can't be replayed against a separately-obtained
  code in the 60s cleanup window
- WWW-Authenticate Bearer parser handles RFC 7235 quoted-string
  escapes (\" and \\) instead of the naive [^"]+ regex
- AS-controlled response bodies and error_description query params go
  through sanitize_log_text before reaching exception messages or
  audit details. AS error responses are parsed for the standard
  RFC 6749 fields (error, error_description, error_uri), each
  capped at 80 chars and run through redact_credentials to defend
  against ASes that echo the request body back into their error
  payload.
- oauth_as_issuer_cached is re-validated against the SSRF guard on
  read; on rejection the column is cleared and PRM rediscovery runs
- DCR / token-endpoint / refresh-endpoint response bodies cap at 64
  KiB (PRM/AS metadata cap stays at 256 KiB) so a hostile or
  malfunctioning AS can't exhaust client memory.
- oauth_client_secret operator input capped at 1024 chars at the
  admin-form boundary; longer plaintext rejected with 400.
- /start and /callback responses stamp `X-Frame-Options: DENY` so the
  redirected pages can't be framed by attacker sites.
- delete_user cascades to mcp_user_tokens and mcp_oauth_pending so
  user deletion no longer leaves dangling per-user OAuth state.
- Renaming or deleting an oauth_user MCP server purges per-user
  tokens and pending OAuth state for the previous server name
  (delete_mcp_oauth_rows_by_server_name). The OAuth tables key on the
  mutable server_name; without this purge, a future server with the
  same name (and an attacker-controlled URL) would silently rebind
  prior user tokens. A future schema migration will replace the
  server_name key with a server_id FK + ON DELETE CASCADE.
- get_user_access_token catches MCPTokenDecryptError (raised when no
  installed key can decrypt the row, e.g. after key rotation) and
  falls through to None so dispatch surfaces a re-consent rather than
  crashing.
- oauth_user MCP server rows are skipped in the static auto-connect
  path. Auto-connecting them at startup with empty headers fails the
  AS check and trips the circuit breaker; per-user tokens come online
  lazily once the user has consented.

Audit (mcp_server.oauth.* prefix):
- consent_started, consent_completed, consent_failed, token_refreshed,
  token_revoked, dcr_registered. _audit_event is async and wraps
  record_audit in asyncio.to_thread so the audit write doesn't block
  the event loop. resource_id on the audit row is the immutable
  server_id (PK UUID) so admin-driven server renames don't break
  event correlation; server_name is exposed in detail for cross-
  reference. dcr_registered detail.has_secret reflects whether the
  DCR-issued secret was actually persisted (the prior code reported
  has_secret=true even on persistence failure).
- _admin_mcp_action audits the immutable server_id, not the mutable
  server_name (which is what the column is — the table's PK was
  always server_id).
- All OAuth-flow log keys use the mcp_server.oauth.* prefix to match
  the audit-action taxonomy.

Lifespan close-order in turnstone.server and turnstone.console.server
is reversed (LIFO) — mcp_oauth → mcp_crypto → oidc — to match init
order.

Deferred until the upcoming per-user pool integration:
- Multi-node refresh-lock contention via pg_advisory_lock
- DCR re-register on token-endpoint 401 (the dispatch path surfaces
  those 401s)
- TTL-LRU caching of decrypted plaintext access tokens
- DNS-rebinding hardening (httpx Transport pin) — documented as
  limitation in oauth_ssrf module docstring

Tests: 7 new test files / ~85 new tests covering discovery precedence
+ PRM quoted-string parsing, PKCE round-trip, SSRF helper extraction,
authorize/callback handlers including 503-on-no-redirect-base + DCR
concurrency + JWT audience polymorphism + callback-error-pops-pending,
refresh single-flight lock, refresh resource-vs-audience regression,
decrypt-error fallthrough, _db_servers_to_config skipping oauth_user,
pending-state CRUD round-trip.
2026-05-04 22:00:23 -07:00
Patrick Buckley 7f132e7230 feat(mcp): token-at-rest encryption layer for OAuth-MCP
Phase 3 of docs/design/oauth-mcp.md. Adds the Fernet/MultiFernet wrapper,
[security] config loader with rotation support, MCPTokenStore CRUD facade,
typed MCPTokenDecryptError that maps to the RFC's mcp_token_undecryptable_
key_unknown class, and a startup gate that fails loud when auth_type=
'oauth_user' rows exist without a configured encryption key.

Crypto module (turnstone/core/mcp_crypto.py):
- MCPTokenCipher wraps cryptography.fernet.Fernet + MultiFernet for
  rotation; encrypt with first key, decrypt by trying each in order
- load_mcp_token_cipher_config reads [security] mcp_token_encryption_keys
  (plural list) or mcp_token_encryption_key (singular), validates each
  key is base64-decodable to exactly 32 bytes
- MCPTokenCipherConfig is repr=False with custom __repr__ that redacts
  raw key bytes (defense in depth against accidental log/traceback leak)
- _key_fingerprint produces an 8-hex-char SHA-256 prefix for audit
  attribution without exposing the key
- MCPTokenStore handles encrypt-on-write / decrypt-on-read for
  mcp_user_tokens and mcp_servers.oauth_client_secret_ct
- get_user_token MUST NOT auto-delete the row on MCPTokenDecryptError
  (test_get_user_token_with_wrong_key_raises_decrypt_error verifies
  the row stays intact across a key-mismatch read)
- initialize_mcp_crypto_state / close_mcp_crypto_state lifespan helpers
  shared between server and console

Storage protocol (5 new ciphertext-only methods):
- set_mcp_oauth_client_secret_ct (dedicated writer; deliberately NOT
  added to MCP_SERVER_MUTABLE so generic update_mcp_server cannot write
  the secret column)
- create_mcp_user_token, get_mcp_user_token,
  update_mcp_user_token_after_refresh, delete_mcp_user_token

Server + console lifespans (turnstone/server.py + console/server.py):
- after OIDC init, count auth_type='oauth_user' rows; if any exist and
  no encryption key is configured, log an actionable error and
  raise SystemExit(1)
- without oauth_user rows, missing key is fine (lazy validation; admin
  flip without restart returns 503 from the admin handler)
- app.state.mcp_token_cipher / .mcp_token_store populated when key
  configured; None otherwise

Admin handlers:
- _require_token_store_for_oauth_secret pre-mutation gate validates
  token_store availability and oauth_client_secret type BEFORE
  storage.create_mcp_server / update_mcp_server runs, so a 503 from a
  missing key never leaves an orphan row or partial-update state
- _apply_oauth_client_secret encapsulates the encrypt + audit write
  used after the storage mutation; rolled out across both create and
  update handlers
- 503 message references both mcp_token_encryption_key (singular) and
  mcp_token_encryption_keys (plural for rotation)
- non-string oauth_client_secret payloads (false / 0 / lists / dicts)
  are rejected with 400 instead of being str()-coerced
- when auth_type transitions away from oauth_user, the encrypted
  secret column is cleared in the same admin call (with audit), so
  flipping back doesn't silently resurrect a stale credential

Audit events (mcp_server.oauth.* per audit.py taxonomy; RFC's
mcp.oauth.* renamed for consistency):
- mcp_server.oauth.client_secret_set fired from admin handlers with
  cleared:bool and key_fingerprint
- mcp_server.oauth.token_decrypt_failure fired from MCPTokenStore
  .get_user_token when no installed key can decrypt; carries
  key_fingerprints_attempted

Tests: 35 new tests across test_mcp_crypto, test_mcp_token_store,
test_server_lifespan_mcp_crypto, plus 6 admin-API tests covering the
no-orphan-row, no-partial-update, secret-clear-on-transition, and
non-string-secret-rejection invariants. Suite at 5337 (Phase 3 added
~50 tests including the rebase-imported skill suite).

cryptography>=42 promoted from transitive (lacme[tls]) to direct dep
since the encryption layer is now core, not optional.

Phase 4 (OAuth flow) wires the actual callers; Phase 3 adds only the
crypto layer and is exercised entirely by tests.
2026-05-04 22:00:23 -07:00
Patrick Buckley d675b237a3 feat(mcp): oauth schema + minimum admin form
Adds the data model and admin UI surface required by the OAuth-MCP flow.
Phase 2 of the per-user delegation initiative.

Schema:
- migration 049 creates mcp_user_tokens (PK user_id, server_name) and
  mcp_oauth_pending (PK state, indexed by created_at)
- eight new columns on mcp_servers: auth_type ('none' / 'static' /
  'oauth_user', NOT NULL DEFAULT 'static') plus six oauth_* config
  fields and oauth_as_issuer_cached
- post-upgrade UPDATE normalises auth_type to 'none' for streamable-http
  rows whose headers are NULL/empty/'{}'; stdio rows are left at the
  'static' default (auth_type is HTTP-auth-only)
- _schema.py kept in lockstep with the migration so metadata.create_all
  and alembic upgrade produce identical shapes
- mcp_user_tokens / mcp_oauth_pending TypedDicts in _protocol.py for
  Phase 3/4 use (no CRUD methods yet)

Storage / API:
- create_mcp_server gains the eight kwargs across protocol + sqlite +
  postgresql
- MCP_SERVER_MUTABLE picks up auth_type and the six text oauth_* fields;
  oauth_client_secret_ct is intentionally NOT in the whitelist — Phase 3
  will own ciphertext writes via a dedicated method
- McpServerInfo + Create/Update Pydantic schemas extended; oauth_client_secret
  accepted as plaintext input but discarded (Phase 3 wires encryption)

Admin handlers:
- _parse_auth_type validates against {'none', 'static', 'oauth_user'} and
  rejects empty / unknown values; shared between create and update
- when auth_type changes away from 'oauth_user', the oauth_* config
  columns are explicitly nulled in the same UPDATE so the row stays
  consistent
- _clean_oauth_text caps text fields at 512 chars (URLs at 2048) to bound
  admin write surface
- _mask_mcp_secrets now masks oauth_client_secret_ct to '***' regardless
  of reveal=true (write-only field)
- audit detail dict redacts oauth_client_secret if present

Frontend:
- new "Multitenant Authorization" fieldset on the MCP-server modal with
  three radio buttons (None / Shared / Per-user OAuth 2.1)
- conditional OAuth subform: AS URL, registration mode (preregistered /
  dcr; cimd is future), client ID, client secret, scopes, audience
- secret input is autocomplete=off and never round-trips on edit
- audience auto-populates from the MCP server URL on blur
- headers textarea hidden and submitted as {} when auth_type is 'none' or
  'oauth_user' so flipping the radio cleans up server-side state

Tests: storage round-trip for the new columns, oauth_pending table smoke,
migration 049 upgrade/downgrade with stdio-vs-http normalisation, four
admin-API tests for auth_type validation and oauth_*-clear-on-flip-away.
Suite passes 5284 (matched pre-Phase-2 baseline 5267 + 17 new).

Stacks on Phase 0; no behavioural change for existing rows.
2026-05-04 22:00:23 -07:00
Patrick Buckley be0950bb98 refactor(mcp): consolidate per-server state into StaticServerState dataclass
Phase 0 of the OAuth-MCP RFC: prepare MCPClientManager for the per-(user,
server) session pool that lands in Phase 5, without changing static-path
behavior.

Two changes:

1. Hardening helpers _pre_close_streams and _tcp_probe rename their first
   parameter from `name` to `key`.  Type stays `str` for now; widening to
   `str | tuple[str, str]` happens in Phase 5 when callers actually pass
   tuples.  _safe_close_stack takes the stack directly and is unchanged.

2. The eleven parallel name-keyed dicts (_sessions, _per_server_stacks,
   _per_server_tools, _per_server_resources, _per_server_prompts,
   _supports_list_changed, _supports_resources, _supports_resource_list_changed,
   _supports_prompts, _supports_prompt_list_changed, _server_streams) are
   consolidated into _static_servers: dict[str, StaticServerState].  Server-
   level state (circuit breaker, notification debounce, last-error,
   db-managed, merged catalog maps, listener lists) stays on the manager,
   unchanged.

PoolEntryState is defined for Phase 5 use but no code instantiates it.  The
typed map declarations (dict[str, StaticServerState] vs dict[tuple[str, str],
PoolEntryState]) make accidental cross-keying lookups easier to catch.

PR #296 hardening preserved exactly:
- pre-close-streams atomic take-and-clear before stack teardown
- stale-session-and-stack guard at _connect_one top: both state.session and
  state.stack checked, cleared independently, entry preserved (not popped)
- transport-error session-eviction in dispatch sets state.session=None only,
  leaving stack/streams for the next connect-time guard sweep
- _safe_close_stack CancelledError suppression unchanged
- TCP probe before streamablehttp_client unchanged
- future.cancel() after TimeoutError in all sync bridges unchanged
- notification debounce stays manager-level (not migrated into the dataclass)

Refresh helpers (_refresh_server_tools/_resources/_prompts) snapshot
state.session into a local immediately after the None guard so concurrent
transport-error eviction during await cannot null the session reference
mid-call.

Tests: shared _seed_static_state helper in tests/conftest.py replaces eleven
direct dict mutations; new test_reconnect_preserves_static_state_identity
guards the entry-preservation invariant.  Pass count rises 5266 → 5267.
2026-05-04 22:00:23 -07:00
Patrick Buckley eb2a119da9 refactor(mcp): remove periodic refresh, add manual refresh/reconnect controls
Deletes the _periodic_refresh task and its supporting state
(_refresh_task, _refresh_failures, _refresh_backoff_until,
_REFRESH_BACKOFF_BASE/MAX, _DEFAULT_REFRESH_INTERVAL, refresh_interval
kwarg) from MCPClientManager. Push notifications and operator-driven
manual refresh now cover all catalog-update needs; the long-running
4-hour timer was dead complexity that obscured the per-user pool
work to come.

Catalog freshness on auto-reconnect is preserved by scheduling an
unblocking _refresh_server task on the mcp-loop after _connect_one
succeeds; the calling thread returns immediately so half-open
recovery latency does not double. Adds MCPClientManager.reconnect_sync
(clears the circuit, closes any existing session, calls _connect_one,
clears stale catalog on failure).

Wires a new pair of operator endpoints —
POST /v1/api/admin/mcp-servers/{name}/refresh and
/v1/api/admin/mcp-servers/{name}/reconnect — that fan out to all
nodes through the existing _internal route family, with per-row
"Refresh" and "Reconnect" buttons in the MCP Servers admin tab.
The new node-internal paths /api/_internal/mcp-{refresh,reconnect}/
are gated to the approve scope to prevent direct unprivileged
reconnects bypassing the console's admin.mcp gate. Internal
endpoints return generic error messages and a filtered status
payload (no command/url) to keep transport details admin-gated.

Drops the [mcp] refresh_interval setting, the
--mcp-refresh-interval CLI flag, and the matching config-mapping
entry; updates docs/architecture.md, docs/tools.md,
docs/settings.md, and the three PlantUML diagrams that referenced
the periodic loop.

Tradeoffs (intentional):
- Idle nodes will not auto-rejoin a recovered MCP server until
  traffic arrives or an operator clicks Reconnect. The previous
  background reconnection loop is gone by design — push
  notifications + operator controls replace it.
- Console fan-out blocks on the slowest node (existing pattern);
  not changed here.

This is Phase 1 of the OAuth-MCP series — feature subtraction
ahead of per-user state.
2026-05-04 22:00:23 -07:00
Patrick Buckley 0a8083e6d5 feat(skills): paste SKILL.md to auto-fill the Create Skill modal (#477)
* feat(skills): paste SKILL.md to auto-fill the Create Skill modal

When a user pastes an Anthropic-style SKILL.md (YAML frontmatter +
markdown body) into the Create Skill content textarea, the frontend
sniffs the leading ``---``, posts the raw text to a new backend parse
endpoint, and populates name / description / tags / author / version /
license / compatibility / allowed_tools from the parsed fields.  The
textarea is left with the body only (frontmatter stripped), and a toast
reports how many fields were set vs. kept (already-typed values are
preserved).

Backend
- ``POST /v1/api/admin/skills/parse`` (admin.skills permission) wraps
  the existing ``turnstone.core.skill_parser.parse_skill_md`` so admin
  imports and external installs share one parser.  ``ParseSkillRequest``
  / ``ParseSkillResponse`` schemas added; OpenAPI spec + sync/async
  console SDK methods updated.
- Hardening: 32 KiB cap on ``raw`` (Pydantic ``max_length`` + handler
  enforcement); ``Content-Length`` pre-check returns 413 before any body
  buffering; parse offloaded via ``asyncio.to_thread`` so deeply-nested
  YAML cannot stall the event loop.

Frontend (turnstone/console/static)
- New paste handler with optimistic paint (raw text shown immediately,
  textarea disabled + ``aria-busy`` flipped, hint switches to
  "Parsing...") so the round-trip is visible on slow networks.
- ``AbortController`` + generation guard (``_ctmPasteController``) so a
  fresh paste or modal close cancels a stale fetch — the previous
  handler's callbacks see the controller has been replaced and bail
  before touching the DOM.
- Non-destructive overwrite: ``_setSkillFormField`` returns "filled" /
  "skipped" / "absent" and refuses to clobber non-empty values.  Toast
  reports counts.
- Bumps ``#toast`` z-index above modal overlays (was 200 vs. modal 600
  — toasts fired while a modal was open were invisible).  Console-wide
  fix exposed by this being the first feature to fire toasts mid-modal.

HTML / CSS
- New ``.skill-paste-hint`` line above the textarea announcing the
  affordance, sized to match surrounding ``.label-hint`` text.
- ``aria-describedby`` ties the hint to the textarea; ``aria-live=
  "polite"`` announces the busy-state transition to screen readers.
- "Skill Content" heading hint reworded "system message — ..." →
  "available: ..." and the variables row label "Variables" → "Used"
  to disambiguate available vs. in-use template variables.

Tests
- 11 new cases in ``tests/test_skill_parse_api.py``: happy paths
  (full / minimal / nested-metadata / unquoted-colon recovery),
  malformed YAML 400, missing/blank/missing-name 400, RBAC 403, raw
  body 32 KiB cap (Content-Length pre-check), chunked-encoding bypass
  forces the application-layer cap.  Test pins ``raw_frontmatter``
  omission so a future ``dataclasses.asdict`` refactor can't silently
  leak the full YAML dict back to clients.

Validation
- 5146 / 5146 ``pytest -k "not live"`` pass.
- ``ruff`` + ``mypy`` clean on changed sources.
- ``node -c`` clean on governance.js.
- Two-stage code review (full pipeline + bug+quality re-review of the
  fix patches) applied; all confirmed findings addressed.

* fix(skills): Copilot PR #477 review fixes (cumulative bug-1, bug-2, q-1)

bug-1 (server.py): Content-Length pre-check was clamped to 32 KiB —
the same number as the per-string char cap on ``raw``.  A legitimate
``raw`` of exactly 32 KiB produces a JSON body well above 32 KiB once
the ``{"raw":"..."}`` wrapper and any escaping is added, so valid
near-max requests were 413'd.  New constant
``_PARSE_SKILL_MAX_BODY_BYTES = _PARSE_SKILL_MAX_CHARS * 4`` admits the
wrapper + multibyte expansion while still refusing obviously oversized
payloads early; the per-string ``len(raw)`` check stays authoritative.

bug-2 (governance.js): hideCreateTemplateModal aborted the inflight
paste controller and nulled the global, but the handler's ``.catch``
and ``.finally`` guard each DOM mutation behind ``_isCurrent()`` —
both bail when the controller has been nulled, leaving the textarea
``disabled`` + ``aria-busy`` and the hint stuck on "Parsing…".
Reopening the modal landed on a poisoned state.  The second-pass
review's q-2 cleanup that dropped the show-side defensive reset
missed this scenario — the verifier's reachability argument confused
"controller is null" with "UI state is reset"; the two are
independent.  Hide now resets the paste-induced visible state
alongside the abort.

q-1 (console_spec.py): error_codes for the parse endpoint listed only
400; handler also returns 413 for oversized bodies.  Added 413; kept
403 implicit per the convention sibling admin endpoints follow.

Test fixup: bumped the Content-Length test payload to 200 KB so it
clearly exceeds the new 128 KB pre-check threshold; otherwise it was
falling through to the per-string check and duplicating
test_oversized_raw_chunked_returns_413's coverage.
2026-05-04 16:12:53 -07:00
Patrick Buckley b2153d907f fix(oidc): close transient client on disable paths + correct docstring
PR #476 review feedback (Copilot, oidc.py:584,616):

1. initialize_oidc_state's docstring claimed "on any failure
   enabled is False" but the JWKS-prefetch failure branch
   intentionally keeps enabled=True so the callback's lazy-fetch
   retry can recover from a transient IdP issue at startup.
   Docstring rewritten to spell out the three post-conditions:
   disable, JWKS-failure-keeps-enabled, success.

2. The long-lived httpx.AsyncClient was created up front, then
   three disable branches (discovery exception, discovery-returned-
   disabled, missing redirect_base) returned without closing it,
   leaving sockets held until shutdown.

   Restructured: discovery now uses a transient AsyncClient inside
   a context manager (closed at exit). The long-lived client is
   only created after the disable checks pass. The JWKS-failure
   branch still legitimately keeps the client open because the
   lazy-retry path needs it.

   The pre-existing single-client-passthrough test was replaced
   with three more specific tests: long-lived client only goes to
   fetch_jwks (not discover_oidc); discovery-exception path leaves
   http_client=None; missing-redirect_base path leaves
   http_client=None.
2026-05-04 14:27:19 -07:00
Patrick Buckley 5d4a50d2cd chore(oidc): consolidate test OIDCConfig helper + fix exceptions banner (cumulative q-4, q-5)
q-4: tests/test_oidc.py's _make_config and tests/test_oidc_handlers.py's
_make_oidc_config built the same OIDCConfig with sensible defaults but
had drifted — only the handlers helper set redirect_base. After b3
made redirect_base operationally required, every test_oidc.py test
that exercised redirect_base had to override it explicitly. A future
test could omit redirect_base and silently exercise the wrong
production path.

Moves make_oidc_test_config to tests/conftest.py with the more
complete handler-version defaults (including redirect_base). Both
test files import it under their existing local alias
(_make_config / _make_oidc_config) so the 60+ call sites in
test_oidc.py and the handler tests don't have to change.

q-5: section banner '# Exception' (singular) at oidc.py:79 became
inconsistent after b5 (callback robustness) added OIDCKeyNotFoundError.
Renamed to '# Exceptions'.
2026-05-04 14:27:19 -07:00
Patrick Buckley 7c6bc22d02 perf(auth): migrate handle_auth_status to count_users (cumulative q-3)
The OIDC perf batch added storage.count_users() and migrated the two
OIDC handlers (handle_oidc_authorize, handle_oidc_callback) but missed
handle_auth_status — which still ran storage.list_users() then
len(users) > 0 for the same has-any-users gate.

count_users() is one COUNT(*) round-trip vs list_users() rehydrating
every row dict. Wrapped in asyncio.to_thread to match the OIDC handler
pattern; the async handler no longer blocks the event loop on storage
I/O for what's effectively an existence probe.
2026-05-04 14:27:19 -07:00
Patrick Buckley d5087ef3b9 fix(oidc): serialise role-mapping concurrency + skip no-op write lock (cumulative bug-2, perf-1)
bug-2 (Postgres) — replace_oidc_roles read existing rows under default
READ COMMITTED with no row lock. Two concurrent OIDC callbacks for the
same user_id (racing token refreshes with differing claim sets) could
both observe the same baseline and produce a final role state matching
neither caller's intent. Adds .with_for_update() to the SELECT so the
existing rows for this user are locked for the duration of the
transaction.

The lock is per-user_id, not table-wide; unrelated user writes are
unaffected. Empty result sets acquire no locks, so a brand-new user
with no rows yet still allows two callers to proceed and merge via
ON CONFLICT DO NOTHING — that's a permissive race that self-heals on
the next reconciliation cycle, documented in code.

perf-1 (SQLite) — replace_oidc_roles took the SQLite global write
lock unconditionally via BEGIN IMMEDIATE before reading. Steady-state
re-logins (claims unchanged, no INSERT/DELETE needed) paid the lock
cost for nothing and serialised against unrelated writers.

Replaces with a double-check pattern: phase 1 reads under the default
deferred transaction (no write lock), computes the diff, and returns
(set(), set()) on no-op. Phase 2, only when mutation is needed,
commits the read txn, escalates to BEGIN IMMEDIATE, RE-READS, and
re-computes the diff under the lock before writing. The returned
(added, removed) reflects what was actually written, so caller logging
in apply_role_mapping stays truthful even when concurrent writers
shifted state between the two reads.

The OR IGNORE on insert is now defense-in-depth (the lock makes it
unnecessary) but kept as a safety net.
2026-05-04 14:27:19 -07:00
Patrick Buckley 3cf87628d2 docs(oidc): document TRUSTED_ENDPOINT_HOSTS + fix three-vs-four required drift (cumulative q-1, q-2)
The 8-commit OIDC stack added TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS
(operator allow-list for cross-host IdP discovery endpoints) and
promoted TURNSTONE_OIDC_REDIRECT_BASE to required, but the docs drifted
in two places:

q-1 — Troubleshooting > "OIDC not configured" still listed three
required env vars. An operator hitting the missing-redirect-base
startup error landed on a debugging entry that didn't mention the
variable they were missing. Fixed; added a separate troubleshooting
entry naming the exact log message produced by initialize_oidc_state
when redirect_base is unset.

q-2 — TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS was undocumented entirely.
Added a row to the env-var table and a new "Cross-host endpoints"
section explaining when the knob is needed (Google is the canonical
multi-origin IdP, but it's auto-handled; the env var is for any other
IdP whose discovery doc legitimately references hosts beyond the
issuer's origin). Added a troubleshooting entry pointing at the new
section.
2026-05-04 14:27:19 -07:00
Patrick Buckley 1c41212f15 fix(oidc): self-heal stranded user when role mapping fails post-create (cumulative bug-1)
If apply_role_mapping raised after create_oidc_user committed (transient
storage failure, race with role deletion, etc.), provision_oidc_user's
inline safety-net was skipped — and on retry the existing-identity
branch never reached the safety-net code, leaving the user permanently
stranded with zero roles.

Extracts _ensure_default_role(storage, user_id, desired_role_ids=None)
helper. Calls it on BOTH the new-user and existing-identity paths so a
user stranded by a transient failure recovers on next login.
desired_role_ids is a hint that lets the helper skip list_user_roles
when claim-driven mapping populated at least one role; the new-user
path was already paying that query, the existing-identity path now
pays it only when claim mapping returned an empty desired set.

Documents the admin-strip behavior in the helper docstring: stripping
all roles from an OIDC user no longer locks them out, since the next
login will re-grant builtin-viewer (assigned_by='oidc-default'). The
documented way to deny an OIDC user is to unlink their OIDC identity
via the admin endpoint, not to strip roles. The pre-fix behavior
(stripped user actually locked out) was the bug.

The 'oidc-default' vs 'oidc' assigned_by distinction is preserved:
apply_role_mapping's revocation lane only touches 'oidc' rows, so the
safety-net role survives every subsequent login regardless of claims.

Six new tests cover both paths, the hint short-circuit, the
list_user_roles fallback, the missing-builtin-viewer no-op, and the
self-heal regression case for already-stranded users.
2026-05-04 14:27:19 -07:00
Patrick Buckley 5c11ab985f test(oidc): close coverage gaps + tighten fetch_jwks shape check (q-5, q-8)
q-5: _derive_username's UUID-retry tier (oidc.py:923-933) was untested.
  After perf-6 collapsed tier-2 to a single find_existing_usernames call,
  the only remaining tail was the 3-attempt UUID-retry loop and the final
  raise. New TestDeriveUsername class covers:
  - falls into UUID retry when all 10 suffix candidates are taken
  - UUID retry succeeds on the second attempt after one collision
  - UUID retry exhausted -> raises OIDCError

q-8: filled the unit-level coverage holes the multi-stage review flagged:
  - test_validate_id_token_retry_after_kid_rotation — direct unit test of
    the OIDCKeyNotFoundError path with real RS256 keys + JWKS rotation
    (previously only exercised end-to-end through the handler).
  - test_callback_uses_pending_audience_not_handler_audience — pins down
    the bug-3 fix by decoding the issued JWT cookie and asserting aud
    matches the audience stored at /authorize time, not the handler param.
  - test_apply_role_mapping_int_claim / _dict_claim — exercises the
    else: values = [str(claim_value)] branch for non-string non-list
    claim shapes.
  - TestFetchJWKS — non-200 status, non-dict body, dict-missing-keys,
    keys-not-list, transport network error.
  - TestExchangeCode network/4xx/5xx error tests (the non-dict-body case
    already shipped in batch 5).

Also a small production hardening that fell out of writing the
TestFetchJWKS::test_fetch_jwks_non_dict_body_raises test: fetch_jwks now
guards isinstance(result, dict) before result.get("keys"), matching the
shape-check pattern that discover_oidc and exchange_code already use.
A list/null body now surfaces as OIDCError("...not a JSON object") rather
than AttributeError leaking up to the lifespan.
2026-05-04 14:27:19 -07:00
Patrick Buckley bae4adca12 refactor(oidc): quality cleanup (bug-3, q-1/3/4/6/7/9/10/11/12/13)
Eleven small maintenance fixes; no behavior change beyond bug-3.

bug-3: pending.get('audience', audience) couldn't fall back because
  pop_oidc_pending_state always returns a dict with the audience key
  set verbatim from a non-null TEXT column. Replaced with
  pending.get('audience') or audience to cover the empty-string case
  defensively. Comment explains the security rationale.

q-1: extract _env_or_cfg_str / _env_or_cfg_bool helpers in oidc.py;
  load_oidc_config's six near-identical env-or-config blocks collapse
  to one-liners. role_map / trusted_endpoint_hosts / redirect_base
  retain bespoke parsing.

q-3: discover_oidc narrows except (httpx.HTTPError, ValueError, KeyError)
  with exc_info=True.

q-4: OIDC_STATE_TTL_SECONDS = 300 constant in oidc.py; auth.py imports
  and passes it explicitly. Storage signatures keep the literal default
  (storage layer doesn't know OIDC TTL semantics).

q-6: hoist runtime imports (OIDCError, OIDCKeyNotFoundError, exchange_code,
  fetch_jwks, provision_oidc_user, validate_id_token, build_authorize_url,
  generate_pkce_verifier) to module scope in auth.py. The genuine cycle
  is only oidc._derive_username -> auth.is_valid_username, kept
  function-scoped. test_oidc_handlers.py mock targets repointed to
  turnstone.core.auth.X to match the new binding.

q-7: comment + docs explain the 'oidc' vs 'oidc-default' assigned_by
  marker distinction.

q-9: OIDCIdentity / OIDCPendingState TypedDicts in storage protocol.
  Implementations construct via TypedDict syntax so mypy structurally
  verifies all required fields.

q-10: fetch_jwks narrows except (httpx.HTTPError, ValueError); docstring
  matches.

q-11: rename generate_pkce_pair -> generate_pkce_verifier; return only
  the verifier (build_authorize_url already recomputes the challenge).

q-12: extract _buildOidcRow helper in admin.js so future field additions
  go in one place.

q-13: OIDCConfig docstring lists startup-config vs discovery-derived
  field groups.
2026-05-04 14:27:19 -07:00
Patrick Buckley 39a647f39c perf(oidc): batch perf hardening (perf-1..8)
Eight independent perf wins on the OIDC hot path:

perf-1: list_users() full-scan setup-gate replaced with new count_users()
  on both authorize and callback. Saves a full users-table fetch per login.

perf-2: handle_oidc_callback's sync DB chain wrapped in asyncio.to_thread
  for cleanup, pop_oidc_pending_state, count_users, and provision_oidc_user.
  handle_oidc_authorize gets the same treatment for count_users and
  create_oidc_pending_state. Event loop no longer blocks for the full
  callback duration on Postgres deployments.

perf-3: apply_role_mapping N+1 collapsed via new replace_oidc_roles
  storage method. One transaction handles the diff + insert + delete
  instead of 2N+1 commits per login. Returns (added, removed) so the
  caller can still emit per-role audit logs.

  The diff respects the documented invariant "manually-assigned roles
  are never touched" — desired_role_ids is filtered against rows where
  assigned_by != 'oidc' before computing added/removed. This prevents a
  PK conflict (Postgres lockout) or silent OR-IGNORE no-op (SQLite lying
  return) when admin-ui or oidc-default already holds the same role_id.

perf-4: provision_oidc_user no longer re-queries list_user_roles after
  apply_role_mapping. The new-user builtin-viewer fallback is gated on
  desired_role_ids being empty, which is information apply_role_mapping
  already returned.

perf-5: JWKS refetch dedup via asyncio.Lock on app.state. Both lazy-fetch
  (cold-start recovery) and rotation paths share the same lock with a
  double-check pattern: re-resolve kid against the current cache before
  issuing a new GET. N concurrent callbacks during rotation now produce
  at most 1 fetch.

perf-6: _derive_username's 9-suffix loop collapsed via new
  find_existing_usernames(candidates) -> set query. Worst case drops
  from 13 sequential queries to 1 + up-to-3 UUID-retry queries.

perf-7: cleanup_expired_oidc_states gated to once-per-60s per process
  via app.state.oidc_last_cleanup_monotonic. The pop already deletes
  the consumed row; the bulk cleanup is only relevant for abandoned
  authorize flows, so frequency was overkill.

perf-8: Long-lived httpx.AsyncClient stashed on app.state.oidc_http_client
  by initialize_oidc_state. discover_oidc/fetch_jwks/exchange_code accept
  an optional client= kwarg; when set, skip the per-call AsyncClient
  context-manager. New close_oidc_state lifespan teardown closes it.
  Tests pass client=None to keep the transient-client legacy path.

New storage methods (sqlite + postgresql):
- count_users() -> int
- find_existing_usernames(candidates) -> set[str]
- replace_oidc_roles(user_id, desired) -> (added, removed)
2026-05-04 14:27:19 -07:00
Patrick Buckley 0af3adae1d fix(oidc): callback robustness — typed exceptions, shape checks, log sanitize, JS race (bug-4, bug-5, bug-6, sec-4)
Four small hardening fixes on the OIDC callback hot path:

bug-4: JWKS rotation retry was matching the substring 'not found in JWKS'
  inside an OIDCError message. A future rephrasing would silently break
  key rotation. Adds OIDCKeyNotFoundError(OIDCError); validate_id_token
  raises the subclass at the kid-not-found site; handle_oidc_callback
  catches it explicitly. Other 'not found' errors in validate_id_token
  remain as plain OIDCError.

bug-5: tokens['id_token'] raised KeyError if the IdP returned 200 without
  id_token. exchange_code now rejects non-dict response bodies; the
  callback validates id_token shape (must be non-empty str) before
  passing to validate_id_token. Both raise OIDCError, surfaced as the
  standard 'Authentication failed' redirect.

bug-6: shared_static/auth.js — the OIDC error display raced showLogin's
  /v1/api/auth/status fetch via a 300ms setTimeout. showLogin now takes
  an optional oidcError parameter and paints it after _switchMode clears
  the error, in both the success and catch branches of the fetch.

sec-4: oidc.py exchange_code's non-200 OIDCError interpolated up to 500
  bytes of attacker-controlled IdP body, which then went to log.warning
  via 'OIDC callback failed: %s'. CRLF in resp.text could forge log
  lines. New _sanitize_log_text helper escapes control chars via
  unicode_escape and caps at the rendered length.
2026-05-04 14:27:19 -07:00
Patrick Buckley 11618bb1d7 fix(oidc): atomic user + identity provisioning to prevent orphan rows (bug-1)
provision_oidc_user previously called create_user (INSERT OR IGNORE
on SQLite — silent no-op on UNIQUE conflict), then create_oidc_identity
(also INSERT OR IGNORE), then apply_role_mapping which writes user_role
rows for the supposedly-new user_id. On a username TOCTOU race or
concurrent (issuer, sub) double-create, both inserts no-opped but
user_role rows were already written — leaving orphan rows pointing
at a user_id that doesn't exist.

PostgreSQL's create_user raised IntegrityError instead of silently
no-opping so it produced a misleading 'Authentication failed' error
without orphans, but the user-facing UX was equally poor.

Adds StorageConflictError to the storage protocol and create_oidc_user
that does both inserts in one transaction. Username collision and
(issuer, subject) collision both raise StorageConflictError, mapped
to OIDCError by provision_oidc_user. Crucially the new code does not
silently bind a colliding-username new identity to the existing user
— that would be an account-takeover vector. It raises.

SQLite uses BEGIN IMMEDIATE inside the try block so lock-contention
errors surface as StorageConflictError instead of leaking the raw
sqlalchemy OperationalError.

PostgreSQL relies on SQLAlchemy 2.x begin-on-demand semantics; the
explicit conn.commit()/rollback() in the catch block is the only
materialization path. Discrimination on PG uses
exc.orig.diag.constraint_name with message-substring fallback.
2026-05-04 14:27:19 -07:00
Patrick Buckley 52aba17740 fix(oidc): require TURNSTONE_OIDC_REDIRECT_BASE; drop Host-header fallback (sec-2)
_build_oidc_redirect_uri previously fell back to the request Host
header when redirect_base was unset. With a permissive reverse proxy
or direct backend access, a spoofed Host minted an authorize URL
pointing to attacker-controlled host — combined with a permissive
IdP redirect_uri allowlist this enables auth-code interception.

There is no production scenario where a Host-derived redirect_uri is
correct, so this fails closed:

- initialize_oidc_state checks redirect_base after discovery succeeds
  and disables OIDC (with an explicit error log naming the env var)
  if it's empty. Runs before fetch_jwks so a misconfigured deploy
  doesn't make a wasted JWKS call.
- _build_oidc_redirect_uri simplifies to f"{redirect_base}/v1/api/auth/oidc/callback".
  request parameter dropped; both call sites (handle_oidc_authorize,
  handle_oidc_callback) updated.
- docs/oidc.md promotes TURNSTONE_OIDC_REDIRECT_BASE from "Recommended"
  to "Required" with the security rationale.
2026-05-04 14:27:19 -07:00
Patrick Buckley 6f9e140a41 refactor(oidc): unify server+console lifespan via initialize_oidc_state (q-2, bug-2)
The OIDC discovery + JWKS prefetch block was duplicated byte-for-byte
between turnstone/server.py and turnstone/console/server.py. The bare
except branch in that block also left app.state.oidc_config unchanged
on unexpected exceptions — leaving the runtime with enabled=True and
empty endpoints, producing malformed authorize URLs.

Extracts initialize_oidc_state(app_state) into turnstone/core/oidc.py
which guarantees a coherent post-condition on every code path:
- discovery exception -> oidc_config replaced with enabled=False, jwks_data=None
- discovery returns enabled=False -> jwks_data=None
- JWKS prefetch fails -> jwks_data=None but enabled=True preserved (the
  callback's lazy-fetch retry path remains the recovery)
- success -> oidc_config + jwks_data both populated

Also hardens discover_oidc against non-dict discovery responses
(list/null/string/int) — previously these raised AttributeError out
of doc.get and propagated past the lifespan's bare except.

server.py and console/server.py lifespan blocks collapse to a single
await initialize_oidc_state(app.state) call.
2026-05-04 14:27:19 -07:00
Patrick Buckley 0df7dc026b fix(oidc): SSRF + plaintext credential exfil via discovery doc (sec-1, sec-3)
OIDC discovery-document endpoints (token_endpoint, jwks_uri,
userinfo_endpoint) were stored verbatim in OIDCConfig and later passed
to httpx without revalidation. Only the issuer URL was checked. A
hostile or compromised IdP could return token_endpoint pointing to an
internal IP (169.254.169.254, 10.0.0.0/8, etc.) and Turnstone would
POST the client_secret there.

Extracts the existing scheme/userinfo/SSRF check into
_validate_url_no_ssrf, adds validate_discovered_endpoint that runs the
same checks plus an issuer-binding check, and wires it into
discover_oidc for authorization_endpoint, token_endpoint, jwks_uri,
and userinfo_endpoint (when present).

Issuer binding accepts:
- Same (scheme, hostname, effective port) as the issuer.
- A hostname in _KNOWN_TRUSTED_ENDPOINT_HOSTS for the issuer (Google's
  multi-origin discovery is in the allow-map by default).
- A hostname in OIDCConfig.trusted_endpoint_hosts, settable via
  TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS env var or config.toml, for
  IdPs not in the static map.

Effective port comparison treats https://host and https://host:443 as
the same origin (urllib.parse.urlparse leaves the explicit form's port
as 443 and the implicit form's as None).

24 new tests cover the validator, the Google known-hosts path, the
operator allow-list, default-port equivalence, foreign-host
rejection, private-IP rejection, embedded credentials, and DNS
rotation between issuer check and endpoint use.
2026-05-04 14:27:19 -07:00
Patrick Buckley 5d6d4436fb feat(console): inline node picker replaces back-to-console banner (#475)
* feat(console): inline node picker replaces back-to-console banner

Drops the 32px banner the console proxy used to inject above proxied
server-UI pages and replaces it with an inline node-id pill in the
existing #ui-header.  Click the pill to open a dropdown that lists
healthy nodes (health dot, ws count, reachable/degraded/unreachable
text) plus a top-row link back to the console.

Reuses the .ws-tab-dropdown shell from ui/static/style.css for
animation, shadow, theme override, and item layout, so the picker
visually matches the workstream-tab chevron menu it sits next to.
Keyboard nav (ArrowDown/Up/Home/End/Tab/Escape) mirrors the chevron
menu's handler with cross-reference comments at both sites.
Lazy-fetches /v1/api/cluster/nodes against the console origin
(bypassing the prefix shim) on first open.

Reclaims 32px of vertical space, consolidates three separate
"you're on node X via console" indicators into one, and turns the
wayfinding chrome into a real cluster-nav primitive.

* fix(console): address Copilot review on node picker

- Request /v1/api/cluster/nodes?limit=1000 (collector's hard cap)
  instead of relying on the default 100 — clusters with more than
  100 nodes were silently dropping rows from the picker.
- Hand off focus to the first menu item after the async fetch
  resolves: openMenu()'s deferred focus hook ran while only the
  skeleton was in the DOM, so first-open keyboard users were
  stranded on the trigger until they pressed an arrow key.
- Tab now closes the menu without preventDefault, so focus moves
  to the next focusable element on the first press (ARIA APG menu
  pattern).  Escape still preventDefault + returns to the pill.
- Cap pill max-width at 240px and ellipsize the id span; node ids
  are accepted up to 256 chars upstream and could otherwise push
  the title and right-side controls off the appbar.  Pill carries
  a title attribute so the full id is still legible on hover.
2026-05-04 02:57:34 -07:00
Patrick Buckley 11f0813329 fix(session): properly inject queued user messages mid-loop (#474)
* fix(session): properly inject queued user messages mid-loop

Two queued-user-message bugs in ``ChatSession.send()``.

**Mid-tool-call: ``Unexpected role 'tool' after role 'user'`` on Mistral.**
The ``supports_tool_advisories`` capability flag (default False for
unknown openai-compatible models) routed cap-off providers down a
short-circuit branch in ``_collect_advisories`` that called
``_flush_queued_messages`` directly. That appended a ``user`` turn
between ``assistant(tool_calls)`` and ``tool``, which mistral-common's
``_validate_message_order`` rejects with a 400.

Drop the flag. All providers now run the unified path: queued user
messages become ``UserInterjection`` advisories that ride inside the
tool result envelope via ``wrap_tool_result``, splicing
``<system-reminder>`` text into the tool message's content. Role
sequence stays ``assistant → tool``. Live-confirmed on Mistral
medium and Qwen3 — both correctly distinguish system-reminder from
tool stdout in their reasoning.

**Mid-stream: queued message orphaned until next user send.**
After a no-tool assistant turn, ``_flush_queued_messages`` would
append the queued user message to history and the loop would
``break``, leaving the message at the tail of history with no
model response. Visible as "two sends to get one reply".

``_flush_queued_messages`` now returns ``bool``. The no-tool branch
``continue``s on drain instead of ``break``ing, so the model gets a
turn over the extended history.

Tests:
- ``test_collect_advisories_drains_text_queued_messages_to_persistent``
  pins the unified-path drain (text-only queue → ``UserInterjection``,
  no separate user turn appended to ``self.messages``).
- ``test_send_continues_when_messages_queued_during_streaming`` pins
  the loop-continue behavior (fails with 1 stream call pre-fix,
  passes with 2 post-fix).

* fix(session,ui): reject queued attachments + paperclip busy state

Copilot pointed out that the attachment-bearing branch in
``_collect_advisories`` had the same role-ordering bug as the
text-only path that 802658f fixed: an attachment-bearing queued
item would still call ``_append_user_turn`` mid-tool-call,
injecting ``user`` between ``assistant(tool_calls)`` and ``tool``.

Pragmatic fix: don't allow attachments to be queued at all.

**Backend.** ``ChatSession.queue_message`` raises a new
``AttachmentsNotQueueableError`` when called with non-empty
``attachment_ids``. The interactive ``/send`` route catches it,
releases reservations via the existing ``_release_reservation_on_fail``
hook, and surfaces ``status: "attachments_busy"`` to the caller
with the IDs in ``dropped_attachment_ids``. The coord adapter
mirrors the cleanup (releases the soft-locked reservation taken
for ``_send_id``) so the create-with-attachments path can't leak.

Now that the queue can never carry attachments, the per-item
``att_ids`` slot is gone:

- Queue tuple slimmed ``(cleaned, priority, att_ids)`` →
  ``(cleaned, priority)``.
- ``_flush_queued_messages`` collapses to a single combined-text
  user turn (no attachment branch).
- ``_collect_advisories`` queue-drain pushes ``UserInterjection``
  advisories only (no ``attachment_items`` list).
- ``dequeue_message`` no longer unreserves (queue can't reserve).
- ``_resolve_attachment_ids`` had no remaining production callers
  and is deleted along with the tests that exercised it in
  isolation.

**Frontend.** ``Composer.setBusy`` disables the paperclip whenever
busy (regardless of ``queueWhileBusy``) — text still queues,
attachments don't. ``chat.css`` gains a ``.composer-attach:disabled``
rule (mirrors the existing ``.composer-send:disabled`` treatment)
so the affordance actually looks unclickable instead of falling
through to the UA default. ``title`` and ``aria-label`` are kept in
sync for AT users (WCAG 4.1.2).

Both interactive and coordinator UIs handle the new
``attachments_busy`` response with a chat-surface error bubble:

> Attachments can't be sent while the assistant is working.
> Send a text-only message now, or wait and resend with attachments.

Chips stay in the composer so the user can retry once idle.

**Tests.** Replaced the now-impossible ``TestQueuedWithAttachments``
class with a rejection-coverage class. Rewrote the
``_queue_with_attachment`` route-test fixture to reserve directly
via ``reserve_attachments`` (the queue path no longer reaches the
reserved state). Added a route-level test for the new
``attachments_busy`` contract.
2026-05-04 01:25:34 -07:00
Patrick Buckley c339615e39 Bound search tool output against pathological inputs (#473)
* Bound search tool output against pathological inputs

Replaces the per-line truncation with a fully bounded pipeline so the
search tool can no longer overflow the LLM context — or OOM the parent —
on minified bundles, multi-GB JSONL records, or huge result sets.

Backend:
- Prefer ripgrep when on PATH; grep is the fallback. Detection is
  cached via functools.cache.
- ripgrep flags do most of the bounding natively: --max-columns 1024
  + --max-columns-preview, --max-filesize 10M, --max-count 100,
  --no-config, --no-messages, plus negative globs for the same
  noisy directories grep has been excluding.
- ripgrep added to the Dockerfile.

Streaming subprocess (_search_capture):
- subprocess.Popen with a streaming, byte-capped stdout read (4 MB).
  Defends against single-line files (training data, minified bundles)
  that would have OOM'd the previous subprocess.run capture.
- threading.Timer watchdog enforces tool_timeout even when the
  pipe read is blocked in the kernel — proc.wait(timeout=…) alone
  was insufficient because the read sat ahead of it.
- Stderr drained in a daemon thread to avoid pipe-deadlock when the
  child writes to stderr while we're still reading stdout. Cap on
  captured stderr keeps a hostile child from growing the buffer.

Tier-based formatter (_format_search_results):
- Tier 1: full path:line:content output, stream-emitted with a
  running-cost short-circuit so we never materialize past the budget.
- Tier 2: K samples per file with overflow notes; K is computed
  analytically from budget / file_count / avg-line-length so we hit
  the right ladder rung in a single pass.
- Tier 3: per-file counts only, also budget-bounded with a tail line
  reporting the omitted files. Sorted by descending count.
- Total output budget (32 KB) is well under tool_truncation, so the
  head+tail _truncate_output strategy never silently drops middle
  files in a search result.

Argument injection fix:
- The ripgrep arg list was missing the `--` separator that the grep
  branch already had. With auto_approve on the search tool, that was
  exploitable: path='--pre=COMMAND' would have made ripgrep run the
  script as a per-file preprocessor and surface its stdout. Added
  `--` and a regression test.

State-machine cleanup in _exec_search:
- rc < 0 (signal-killed by something other than us) now surfaces a
  dedicated 'killed by signal N' message instead of being parsed as
  success.
- capped + zero parsed records (e.g. one multi-MB line with no \n)
  now returns a dedicated byte-cap message instead of the malformed-
  output message that previously masked the real cause.
- _report_tool_result descriptions now match the returned payload
  (no more 'no matches' tag on a 'malformed' payload).

Defence-in-depth on env scrub:
- RIPGREP_CONFIG_PATH, GIT_CONFIG, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM
  added to _EXPLICIT_SCRUB. We pass --no-config on the rg CLI today,
  but if a future caller forgets the flag, an attacker who can set
  one of these env vars could plant a config containing --pre=… and
  recreate the same RCE shape.

Tests:
- TestSearchLineTruncation rewritten to mock _search_capture instead
  of subprocess.run (the previous tests passed ChatSession kwargs
  that no longer satisfy the constructor).
- TestSearchBackendSelection covers rg/grep detection and arg
  construction, including the --pre flag-injection regression.
- TestSearchOutputBudget exercises Tier 1/2/3 directly.
- TestSearchCaptureStreaming spawns real Python subprocess writers
  to exercise the byte-cap trim, mega-line-no-newline edge case, the
  watchdog timeout when the child writes nothing, and the stderr
  drain under load.
- test_env_scrub picks up the new tool-config keys.

* Address Copilot review on #473

- Budget the Tier 2/3 header up front so the formatter's emission stays
  strictly within _SEARCH_OUTPUT_BUDGET. Previously the fit checks only
  counted body bytes, letting the final string overflow by ~120 chars
  (header + separator) and triggering _truncate_output's head+tail
  dropout — exactly the shape this code was trying to avoid.
- Restore the (5, 3, 1) ladder in Tier 2: the analytical K from perf-2
  is kept as a starting estimate, but if that K's actual emission
  doesn't fit (the estimate ignores the header and overweights shared-
  path compression) we step down through the ladder before falling
  through to Tier 3. The previous one-shot K could collapse to counts-
  only when 3/file or 1/file would have fit.
- Only normalise rc to 0 in the capped-output path when rc < 0 (our
  SIGKILL). There's a narrow race where the child can exit naturally
  between our read and our kill; preserving a non-negative rc means
  rg's rc=2 ('matches found but some files had errors') no longer
  silently turns into a clean success when the byte cap also fires.
- Clarify _MAX_SEARCH_LINE_LENGTH doc: the cap applies to the content
  portion (after path:lineno:), not the whole emitted line.
- Add explanatory comments on the two intentional `except Exception:
  pass` blocks in _search_capture (stderr drain, pipe close in the
  cleanup finally) so static analysis and future readers can see the
  silence is deliberate.
- Tighten the budget tests: now assert strict `<= _SEARCH_OUTPUT_BUDGET`
  instead of the +512-char slack that was masking the header overflow.
- New regression tests:
  - Tier 2 ladder step-down (K=5 over budget, K=3 fits, no Tier 3 fall-through)
  - capped + rc=2 surfaces stderr instead of being normalised to success
  - capped + rc<0 (our SIGKILL) flows through as a partial-result success

* chore(search): post-review cleanup

Follow-up to the Copilot-review fixes in 39d2aa2 — these are all small
quality items (no behaviour change, no new tests).

- q-1: collapse the Tier 2 candidates filter to a single expression.
  Drops the redundant inner ``max(estimated_k, 1)`` and the unreachable
  ``if not candidates`` branch (the ladder ends in 1 and ``estimated_k``
  is already floored at 1, so the comprehension always yields ≥ ``[1]``).
  ``or [...]`` is kept as defence against future ladder changes.
- q-2: update _format_search_results docstring to match the new ladder
  semantics (analytical seed → step down through (5, 3, 1) from the
  highest rung ≤ the estimate). The previous wording suggested every
  Tier 2 attempt started at 5.
- q-3: combine the two ``from turnstone.core.session import ...``
  statements in test_tier2_steps_down_ladder_before_falling_to_tier3
  into a single top-of-function import (matches the surrounding tests).
- q-4: shorten the explanatory comments on the two best-effort cleanup
  paths in _search_capture to one line each. Both sites now read with
  the same shape ("# best-effort: pipe may be torn down by ...").
- q-5: trim the _MAX_SEARCH_LINE_LENGTH comment from 7 lines back to 3.
  Keeps the load-bearing semantic (cap is on the content portion only)
  and the pathological-line defence; drops the paths-aren't-bounded
  parenthetical, which was background reading rather than WHY.
2026-05-04 00:46:43 -07:00
renovate[bot] 171c8e438f chore(deps): lock file maintenance 2026-05-03 23:51:49 -07:00
renovate[bot] b9b723ba93 chore(deps): update github actions 2026-05-03 23:51:35 -07:00
Patrick Buckley 32fd8f29c7 feat(providers): api_surface toggle + mistral medium reasoning fix (#469)
* feat(providers): api_surface toggle + mistral medium reasoning fix

Mistral medium open-weights served by vLLM expects reasoning_effort via
the Responses API (`reasoning.effort`), not as a `chat_template_kwargs`
entry on Chat Completions.  The session was unconditionally injecting
`{"reasoning_effort": ...}` into `chat_template_kwargs` for every
openai-compatible request, which corrupted the prompt rendering for any
backend whose chat template didn't consume that key (Mistral medium,
Mistral cloud, Groq, OpenRouter).

Changes:
- Add `api_surface` ("chat" | "responses") to `ModelConfig.server_compat`
  and thread it through `create_provider` / `model_registry.get_provider`.
  `openai-compatible` defaults to Chat Completions; operators can flip
  individual aliases to Responses for endpoints that support it.
- New `vllm-mistral-medium` profile that pre-fills api_surface=responses
  on Detect for known Mistral medium model ids.
- Drop the unconditional `reasoning_effort` injection into
  `chat_template_kwargs`.  Operators running gpt-oss-style local
  templates that consume `reasoning_effort` from the chat template now
  opt in via `server_compat.extra_body.chat_template_kwargs`.
- New "API Surface" select in the Models admin tab; allowlist-validated
  server-side at create/update time; pre-filled by Detect via the
  profile suggestion.
- Evict the cached provider singleton in `ModelRegistry.reload()` when
  api_surface changes (previously only cfg.provider triggered eviction).
- Fix `_run_agent` fallback path to inherit the session's primary alias
  for capability and server_compat resolution; previously the fallback
  passed `alias=None`, which silently dropped per-model caps on the
  agent path.

Tests: 5117 passed (-m "not live"); ruff + mypy clean.

* fix(providers): don't auto-suggest Responses for Mistral medium

vLLM's Responses API surface for Mistral medium open-weights doesn't
wire up the Mistral tool-call parser as of vLLM 0.x — tool calls leak
into the response as ``[TOOL_CALLS]<name>{...}`` text instead of
structured tool_calls.  Chat Completions on the same engine handles
tools cleanly via ``--tool-call-parser mistral``, and reasoning can be
turned on via the vLLM CLI ``--reasoning-parser`` flag.

Drop the auto-suggest mapping so Detect falls back to the generic
``vllm`` profile.  Keep the ``vllm-mistral-medium`` profile definition
in place so an operator who specifically wants per-request effort and
accepts the tool-calling limitation can still pick "Responses API"
manually in the admin UI.

* fix(providers): address Copilot review on PR #469

- providers/__init__.py: drop the redundant *_responses_provider /
  *_chat_provider names; have create_provider use _openai_provider and
  _openai_compat_provider directly so they're not flagged as unused
  globals.
- console/server.py: tighten _validate_api_surface to a strict equality
  match against the canonical {"chat", "responses"} set.  The previous
  strip().lower() membership check accepted ' Responses '/'CHAT' but
  stored the raw string verbatim, which then failed to round-trip
  through the admin <select>.
- console/static/admin.js: gate the entire server_compat block (server
  type, api_surface, extra_body) on provider == "openai-compatible" at
  save time so toggling provider away can't leave a stale hidden surface
  selection in the persisted capabilities JSON.
- tests/test_session.py: splat the bad kwarg via **dict so CodeQL no
  longer flags the call as a wrong-name keyword (the point of the test
  is the runtime contract, not the static type).
- tests/test_admin_model_registry_refresh.py: add endpoint-level tests
  for the api_surface validation on both create and update — covers the
  bogus-value rejection, non-canonical-string rejection, and the happy
  path persisting through to the refreshed registry.
2026-05-03 13:37:50 -07:00
Patrick Buckley 89b6b299f7 fix(memory): query-aware candidate selection + OR-of-terms search (#468)
* fix(memory): query-aware candidate selection + OR-of-terms search

The system-message memory composition path used a recency-ordered
candidate set (`_list_visible_memories(limit=fetch_limit)`).  On
deployments with more than `fetch_limit` (default 50) visible
memories, BM25 only ever ranked the 50 most-recently-touched memories
— a relevant memory written months ago was silently invisible
regardless of how well it matched the recent context.  Multi-word
search at the SQL layer used AND-of-terms, killing recall on any
multi-word query without an exact field overlap.

## Functional changes

- `_init_system_messages` (`turnstone/core/session.py`): extract
  recent context first, then `_search_visible_memories(context)` to
  pull query-aware candidates.  Search hits below `fetch_limit` union
  with the recency list (deduped by memory_id) so the BM25 candidate
  pool is always a SUPERSET of the prior recency-only pool — even on
  noisy queries where the cap fills with stopwords, the recency-50
  the original bug surfaced still reaches BM25.  Empty context skips
  search entirely.  Candidate-selection logic extracted into
  `_select_memory_candidates`.

- `search_structured_memories` (PostgreSQL + SQLite): per-term
  clauses join with OR instead of AND.  A row matches if ANY term
  matches ANY of name/description/content.  Downstream BM25 narrows
  back down by relevance.

## Perf hardening

- Collapse the 1-3 fanned scope queries into a single SQL.  New
  backend methods `list_visible_structured_memories` /
  `search_visible_structured_memories` union the visibility scopes
  into one WHERE OR-group, so a composition rebuild now hits the DB
  at most twice (search + recency) instead of up to six times.

- Cap and normalize search terms.  Composition can hand a multi-KB
  pasted message to ILIKE-based search; without a cap, every distinct
  token would emit one unindexable predicate per scope-fanned query.
  `normalize_search_terms` (`storage/_utils.py`) de-dupes
  case-insensitively, drops <2-char tokens, and hard-caps at 16.

- Per-turn search cache.  `_init_system_messages` fires from many
  call sites within one turn (state transitions, MCP refresh, tool
  results) and the recent-context query is identical across them.
  Session-instance cache keyed by (query, mem_type, limit) absorbs
  the duplicates; invalidated in `_append_user_turn` and after
  memory save/delete tool actions.

- Stable secondary sort by `memory_id`.  `updated` is second-precision
  and `touch_structured_memories` can land a batch on identical
  timestamps; without a tie-breaker SQL returns rows in
  implementation-defined order, BM25 input shuffles, and the
  LLM-side prompt cache misses across calls.  All four backend ORDER
  BYs now break ties on `memory_id ASC`.

## Quality cleanups

- Coalesce `memory.search.term_count` + `memory.search.zero_results`
  into a single `memory.search` log carrying both `term_count` and
  `result_count`.
- New `memory.composition` log: source / candidates / injected.
- Promote a shared `make_chat_session` factory to `tests/_helpers.py`.
- Rename SQL builder local `extra` -> `scope_filters` for clarity.
- Add docstrings on `search_structured_memories` so the AND->OR flip
  survives future readers.

## Tests

Adds 20 tests across `tests/test_structured_memory.py`,
`tests/test_structured_memory_storage.py`, and
`tests/test_memory_relevance.py`: recency-ceiling regression,
empty-query fallback, sparse-match union, recency-preserved-when-
search-returns-noise (locks in the pool-superset invariant),
OR-of-terms on both backends, scope filtering preserved,
search-facade multi-word behavior, term-cap normalization, the new
visible-scope helpers (list + search + empty-scopes guard),
coord-scope composition isolation, end-to-end
`memory(action='search')` tool execution, per-turn cache hit +
invalidation, and stable ordering under tied `updated` timestamps.

Memory test sweep: 102/102.  Broader regression
(session, storage, coordinator, load_skill): 411/411.

* fix(memory): address Copilot review on PR #468

Three follow-ups from Copilot's inline review:

1. SUPERSET invariant violation (Copilot, session.py:5510).
   `(search_hits + extra)[:fetch_limit]` capped the union back down to
   fetch_limit, evicting the recency tail when search added distinct
   hits.  Recency tail is exactly where ancient-but-recently-touched
   memories live — the recall this PR is supposed to improve — so
   tail eviction recreated the bug for the narrow case where a query
   term fell off the 16-cap and the matching memory sat in
   recency[40-49].  Drop the cap; both halves are already SQL-capped
   at fetch_limit, so the union is at most 2 × fetch_limit (~100 with
   defaults).  BM25 over 100 candidates in pure Python is sub-ms;
   irrelevant recency fillers get score=0 and don't pollute ranking.
   Updates the docstring to actually be honest about the invariant.
   Adds `test_recency_tail_preserved_when_search_adds_distinct_hits`
   that locks the behavior in: 5 search hits + 10 recency = 15-item
   pool, every recency item present, source="union".

2. Unbounded `query.split()` in normalize_search_terms (Copilot,
   _utils.py:74).  `str.split()` allocates the full token list before
   the cap-after-16 break, so a 100KB pasted query did MB of throwaway
   work even though only 16 tokens entered SQL.  Switch to
   `re.finditer(r'\S+', query)` — streaming iterator, stops scanning
   at the first 16 normalized terms regardless of input size.

3. Misleading + unbounded log term_count (Copilot, session.py:8571).
   `len(item["query"].split())` had two problems: same unbounded
   split as #2, and the value reported the raw input token count
   rather than the normalized term count that actually hit the SQL
   WHERE clause — misleading metric for an operator trying to
   understand storage-side behavior.  Switch to
   `len(normalize_search_terms(item["query"]))` — accurate count, and
   bounded for free via #2.

Refuted: github-code-quality flagged `...` bodies in the new Protocol
methods as "statement has no effect."  False positive — `...` is the
canonical Protocol body convention, used 213 other times in the same
file.

Memory test sweep: 103/103.  Broader regression: 411/411.
2026-05-02 23:52:49 -07:00
Patrick Buckley 9c9333ebd4 fix(tests): isolate metrics-singleton swaps so they don't leak across files
CI failure on main: test_publish_records_metric_outcome saw an empty
calls list — its monkeypatch was patching a different metrics
instance from the one `_publish_models_metadata` reads.

Two changes:

- test_close_reason_persistence.py: replace the bare
  `srv_mod._metrics = MetricsCollector()` assignment in `_make_app`
  with an autouse `monkeypatch.setattr(srv_mod, "_metrics", ...)`
  fixture so the test's metrics swap auto-restores. Other test
  files (test_auth.py, test_server_attachments_endpoints.py) carry
  the same anti-pattern; left for a follow-up since they're not on
  the critical path here.

- test_server_node_models_metadata.py: switch the publish-helper
  metric test to a string-form `monkeypatch.setattr("turnstone.
  server._metrics", FakeMetrics())` so it replaces whatever binding
  the live module currently holds, regardless of what other tests
  did to it. Robust against future leaks of the same shape.
2026-05-02 20:22:25 -07:00
Patrick Buckley 47cf6dea24 feat(coord): expose healthy model aliases per node on list_nodes (#466)
* feat(coord): expose healthy model aliases per node on list_nodes

Surfaces a `model_aliases` field on each `list_nodes` row so a
coordinator can discover which model aliases each cluster node will
accept on `spawn_workstream(model=...)` without an HTTP fan-out.

Each server projects its registry into a `models` entry on
`node_metadata` (`{alias, provider, healthy}` per alias) at lifespan
startup, on every 30s heartbeat tick, and after `internal_model_reload`.
The publish helper short-circuits on a payload-equality cache so a
stable cluster doesn't pay UPSERT churn — exposed via the new
`turnstone_node_models_publish_total{outcome="written|skipped"}`
Prometheus counter so operators can graph cache hit-rate.

Coord client filters the per-alias rows to healthy aliases only and
drops the provider-side model identifier (`cfg.model`) — coords kept
reaching for it when they should pass the local alias.

* fix(coord): address Copilot+CodeQL feedback on list_nodes models work

- internal_model_reload: reuse a single get_storage() local across the
  registry load and the metadata publish (Copilot:3047)
- _collect_node_models_metadata: iterate sorted aliases so two
  structurally identical registries built in different insertion orders
  serialize to the same JSON — directly improves the publish-cache hit
  rate exposed via turnstone_node_models_publish_total (Copilot:3105)
- tests: drop mixed turnstone.server import style flagged by CodeQL —
  hoist _metrics into the from-import block, and use sys.modules in
  the shutdown-race regression test instead of `import as srv`
2026-05-02 19:54:13 -07:00
Patrick Buckley 3308e3645a fix(core): scope rehydrate fallback to manager, fix resume orphan
Address Copilot feedback on PR #465:

1. The has_alias fallback in both session_factories silently rewrote
   any unknown caller-supplied alias to the default, including on the
   fresh-create path where the create handler maps the factory's
   ValueError to a 503 with operator-friendly text. A typo in
   body.model would now silently start a workstream on the default
   instead of telling the caller their requested model could not be
   resolved. Move the fallback out of the factories: each factory
   raises again on unknown aliases, and SessionManager filters stale
   aliases out of the rehydrate path via a new ``model_validator``
   constructor kwarg (production wiring passes ``registry.has_alias``
   on both interactive and coordinator).

2. ChatSession.resume()'s elif branch flipped self.model to the
   persisted model name even when the alias was unresolvable, leaving
   the session paired with the constructor's default provider/client
   but a removed model name — a broken state whose next API call
   fails. Drop the model copy: keep the constructor's coherent
   default (provider + model + capabilities) and just log the
   unreachable saved values so the missing alias is auditable.

Tests:
- Move stale-alias coverage from the factory level into
  SessionManager (tests/test_session_manager.py): validator drops
  stale aliases before reaching build_session; live aliases pass
  through unchanged.
- tests/test_sessions.py renamed test_resume_restores_model →
  test_resume_keeps_defaults_when_alias_unresolvable to match the new
  contract.
2026-05-02 19:02:48 -07:00
Patrick Buckley 0a43bed3d5 fix(core): preserve workstream model + config on rehydrate
SessionManager.open() was calling build_session(ws) without a model
arg on the rehydrate path. The session_factory then resolved the
*current* default alias, ChatSession.__init__'s _save_config() (INSERT
OR REPLACE per-key) clobbered the persisted workstream_config with
those defaults, and the subsequent resume() "restored" what was now
the default — silently resetting model_alias, model, temperature,
reasoning_effort, max_tokens, skill, creative_mode, instructions,
token_budget, and notify_on_complete on every reopen and every
service restart, for both interactive and coordinator workstreams.

Three layers:

1. SessionManager.open() now reads workstream_config via
   self._storage.load_workstream_config(ws_id) and threads the saved
   model_alias into build_session(ws, model=saved_alias).

2. ChatSession.__init__ now skips its initial _save_config() when a
   workstream_config row already exists for self._ws_id — protects
   every other persisted knob without having to plumb each one
   through the adapter signature, and catches any future construction
   path that forgets to thread model through build_session.

3. Both session_factories (server.py interactive, console
   session_factory.py coordinator) now treat an unknown caller-
   supplied alias the same as an unset alias: fall back to the
   runtime default rather than raising. Without this, a workstream
   pinned to an alias an operator has since removed from the registry
   would 500 on every reopen — defeating the "best effort restore,
   default if the original is gone" contract this fix is meant to
   deliver. Mirrors _effective_default_alias's existing has_alias
   guard against a stale ConfigStore default.
2026-05-02 19:02:48 -07:00
Patrick Buckley 7db7f99dd8 fix(console): address Copilot feedback on Models → Roles sub-tab
Three changes from PR review:

- Permission gating: hide the Roles sub-tab button when the user
  lacks ``admin.settings``.  The sub-tab loads/saves through
  ``/v1/api/admin/settings``, so an admin with ``admin.models`` but
  no ``admin.settings`` would otherwise see a perpetual 403 loader.
  When Roles is the active sub-tab and the permission check fails,
  snap the panel back to Definitions so the user lands somewhere
  usable.

- Drop the redundant ``/v1/api/admin/model-definitions`` fetch from
  ``loadAdminModelRoles``.  Both entry points (initial Models-tab
  open + ``models_changed`` SSE refresh) flow through
  ``loadAdminModels`` first, which already populates ``_modelDefs``
  + ``_modelDefaultAlias``; ``_saveModelRole`` doesn't touch model
  definitions, so the cached snapshot stays accurate when the save
  chains back here.  Halves the per-render request count and
  removes a wasted round-trip on every cluster-wide model edit.

- Add ``test_models_changed_event.py`` covering the SSE fanout the
  prior commit introduced: each model-definition CRUD endpoint
  emits exactly one ``models_changed``, settings PUT/DELETE only
  emit for keys in ``_MODEL_AFFECTING_SETTING_KEYS`` (parametrised
  over all eight), and unrelated settings (e.g.
  ``session.retention_days``) don't trigger spurious refreshes.
  The expected key set is pinned in the test so a stray addition
  to the allowlist doesn't silently bypass coverage.
2026-05-02 17:47:12 -07:00
Patrick Buckley 35a1c50e60 feat(console): add plan_agent + task_agent to Models → Roles
Same shape as the coordinator/judge rows already there: alias dropdown
+ reasoning_effort dropdown sourced from the existing
``model.plan_alias`` / ``model.plan_effort`` and
``model.task_alias`` / ``model.task_effort`` settings.  Adds the four
keys to the SSE ``models_changed`` allowlist so changes from the
Settings API also trigger a live dropdown refresh, and filters them
out of the Settings tab so they only render in one place.
2026-05-02 17:47:12 -07:00
Patrick Buckley 4afb192f94 feat(console): consolidate role-model settings + live-refresh dropdowns
Lifts judge and coordinator model assignments out of their respective
admin tabs and into a new Models → Roles sub-tab so role overrides live
next to the model definitions they reference. Forward-looking shape for
the upcoming perception.{audio,image,video} model settings — adding a
new role is one entry in the declarative MODEL_ROLES array.

Also drops the misleading "Coordinator subsystem not configured" home
banner. The session factory already falls back to the registry's
default model when coordinator.model_alias is unset, so the banner was
nagging on fresh installs where the system was actually working. The
related _probeCoordSubsystem / _homeCoordReady plumbing went with it.

Wires SSE-driven live refresh: the console now emits a models_changed
event when a model definition is created/updated/deleted/reloaded, or
when a model-affecting setting (model.default_alias, judge.model,
coordinator.model_alias, coordinator.reasoning_effort) changes.
Connected browsers refetch /v1/api/models on receipt so the home
composer's model dropdown and the Roles sub-tab stay accurate without
a manual reload — fixes the case where editing the underlying model
for an existing alias left the dropdown showing the old model id.

Companion cleanups:
- Renamed .judge-section-* CSS classes to .admin-subtab-* and shared
  them with the Models sub-tab switcher (same a11y attrs, arrow-key
  nav). Old names had no other callers.
- Filtered judge.model out of the Judge Settings sub-tab and
  coordinator.model_alias / coordinator.reasoning_effort out of the
  Settings tab — they live exclusively under Models → Roles now.
- Reworded the _require_coord_mgr 503 messages to point operators at
  the Models tab instead of suggesting they set coordinator.model_alias.
2026-05-02 17:47:12 -07:00
Patrick Buckley 6c28ac828f docs(skills): add import-conversation-history SKILL.md
Source-agnostic guide that teaches an agent Turnstone's destination
contracts (workstream + conversations schema, ws_id routing, OpenAI
message shape, tool-call/result pairing, provider_data fidelity blob,
attachment lifecycle) so it can map any external chat export onto them.
Validated against turnstone.core.skill_parser.
2026-05-01 16:16:17 -07:00
Patrick Buckley ae4fddfc5a fix(console): home composer attachments + coord chat user-message pills (#462)
* fix(console): home composer attachments + coord chat user-message pills

Two parity gaps in the console's coordinator surface:

- The embedded creator on the home page accepted only text — the
  paperclip / paste / drop pipeline that the in-coord composer and the
  interactive new-ws modal both expose was missing, so a user couldn't
  attach files at create time. Stage Files in memory (no ws_id yet) and
  ship them multipart on Start; the coord create endpoint already accepts
  multipart via create_supports_attachments=True.

- User messages with attachments rendered as plain text on both live
  send and history replay — no chip cluster like the interactive pane.
  Added appendUserMessageWithAttachments and a structured userAttachments
  list built from _attachments_meta (preferred) or the multipart parts
  themselves, then rendered the same .msg-user-attach pill strip the
  interactive pane uses.

Polish from a designer pass:

- Pill background was --panel-2, equal to the .msg bubble background in
  both themes (border contrast ≈1.4:1, below WCAG 1.4.11). Switched to
  --panel so the pill sits on a different surface than the bubble.
- Capped chip filename width inside the home composer (max-width 200px +
  ellipsis) so a long filename doesn't push the strip past the textarea.
- aria-live="assertive" → "polite" on #home-coord-error; client-side
  validation isn't an interrupt-level event.
- Reserved min-height on .home-composer-error and dropped the
  display: none/block toggling so validation messages no longer reflow
  the active-coordinators list below.

* fix(console): address PR #462 review feedback

- Block home-composer submit when files are staged but the task field is
  empty.  Server's _coord_create_post_install short-circuits on an empty
  initial_message, so the multipart upload would create pending
  attachment rows that never reserve onto a turn — orphaned until the
  GC sweep.  Fail in the browser instead.
- Drop the redundant `part &&` guard in coordinator.js's history-replay
  multipart loop; the earlier `if (!part || ...) continue` already
  filtered.
- Rewrite the home-mount .composer-chip-name CSS comment.  shared/chat.css
  defines .composer-chip{,-size,-remove} but no .composer-chip-name rule
  — the span inherits the parent chip font with no width cap.
- Add smoke-guard string assertions in test_coordinator_page.py for
  appendUserMessageWithAttachments and msg-user-attach so a future
  rename can't silently regress the attachment affordance.
2026-05-01 16:04:30 -07:00
Patrick Buckley eaabc79eb3 fix(replay): repair saved-workstream tool result rendering + extend audit-trail decoration (#461)
* fix(replay): repair saved-workstream tool result rendering + extend audit-trail decoration

Loading a saved workstream silently dropped tool results and missed
verdict / output-guard / truncation signals on replay. Root cause was
in `Pane.prototype.replayHistory`: an assistant message carrying both
content and tool_calls cleared the `lastToolBlock` anchor before the
following tool-result iteration could attach. The fix reorders content
to render before the tool block (matching live SSE order) and
restructures the tool-result branch to anchor by `data-call-id` so
multi-tool batches render `[hdr A][out A][hdr B][out B]` rather than
bunching outputs at the bottom.

Beyond the bug, replay now reaches near-parity with the live UX:

- Persisted intent verdicts and output_assessments flow through both
  the SSE replay (`_build_history`) and the `/history` REST endpoint
  used by coord. Single shared helper module owns the wire shape.
- Memory/recall calls persist instead of being filtered at storage
  time — full audit trail; UI dims them by default with hover-reveal
  so heavy memory usage doesn't crowd the narrative.
- Truncation indicator surfaces as a sibling pill (consistent across
  interactive + coord) when a tool result hit the 2000-char cap.
- `replayHistory` wraps DOM work in `aria-busy` so screen readers
  don't get a chatty announce-flood on long replays.
- `_build_history`'s storage I/O moves off the event loop via a new
  `events_replay_prepare` async hook for the SSE path; other async
  callers wrap in `asyncio.to_thread`.

Coord parity:

- `/history` REST endpoint decorates tool_calls with verdict +
  output_assessment + truncation flag (was previously raw
  `load_messages` output).
- Coord JS stamps `judge_verdict` / `heuristic_verdict` from
  history-loaded `tc.verdict` so the existing batch render paints
  the persisted pill, seeds the verdict cache to dedupe later live
  SSE events, and emits an inline `.coord-tool-row-warning` chip
  per call instead of a generic chat line.
- Memory/recall dim rule mirrored on `.coord-tool-row[data-tool-name=...]`.

* fix(replay): address PR #461 review feedback + raise tool-result storage cap

Copilot review feedback:

- Sibling-chain dim rule (memory/recall) now adds :focus-within
  alongside :hover for .tool-output / .media-embed / .output-warning
  / .tool-output-truncated — keyboard users tabbing into a faded
  subtree now get full opacity.
- ``cfg.open_post_load`` is now invoked via ``await asyncio.to_thread``
  so its sync ``_build_history`` call (storage I/O for verdict
  indexes + message reconstruction) doesn't block the event loop on
  every workstream open. Mirrors the SSE replay path that's already
  protected via ``events_replay_prepare``.
- Replaced the hardcoded ``2000`` literal in server.py and session.py
  with ``TOOL_RESULT_STORAGE_CAP`` from the shared decoration module
  so the UI truncation-pill detection can't silently desync from the
  storage write side.

While here:

- Raised ``TOOL_RESULT_STORAGE_CAP`` from 2000 → 10000. A 2000-char
  clip routinely cut grep / file-read bodies mid-line, leaving the
  audit trail useless for retrospective debugging. FTS5 + row size
  grow proportionally; the per-tool upper bound is still bounded
  upstream by ``_truncate_output``'s context-budget clamp.
- Updated the user-visible truncation-pill tooltip on both
  interactive and coord to reflect the new cap.
- ``test_decorates_tool_calls_and_marks_truncated`` now references
  the constant instead of a literal so it stays correct on future
  cap changes.
2026-05-01 14:05:07 -07:00
Patrick Buckley 29181687d3 refactor(coord): remove priority queue + queue depth indicator + broken CSS
Speculative reliability machinery from the Stage 3 push that turned
out not to address any user-visible bug. The actual fixes (state /
activity disjunction in handleChildState, bulk-fetch race fix in
_fetch_live_block, push approve_request via cluster bus) are what
resolved the wedged-row issues. Manual testing showed the per-tab
SSE listener queue depth never climbed past single digits even when
rows were stuck — overflow was never the cause.

Removed
- ``_CRITICAL_EVENT_TYPES`` + ``_put_with_priority`` helper.
- Per-tab listener queue selective drop (back to plain
  ``contextlib.suppress(queue.Full)`` everywhere).
- ``ClusterCollector._fanout`` reverts to the same.
- WebUI ``_broadcast_intent_verdict`` / ``_broadcast_approval_resolved``
  / ``_broadcast_approve_request`` revert to plain ``put_nowait``.
- ``_queue_stats`` periodic SSE emit + frontend status-bar indicator
  + the supporting CSS rules.
- Broken ``.approval-block`` ``transition: max-height`` /
  ``max-height: 80vh`` / ``overflow: hidden`` rules — the transition
  never fired (nothing toggled max-height) and ``overflow: hidden``
  clipped long verdict reasoning. Layout-shift on auto-expand jumps
  again, which is preferable to clipped content (Copilot review).

Tidied
- ``_CollectorProtocol`` / ``_ManagerProtocol`` method bodies switch
  from ``...`` ellipsis to docstring-only bodies, silencing four
  CodeQL "statement has no effect" warnings without changing the
  Protocol contract.

5024 passed, ruff + mypy clean.
2026-04-30 23:20:14 -07:00
Patrick Buckley 38a0d9c3b6 feat(coord): Stage 3 SessionManager Children primitive lift + cluster bus push paths
Lift the Children primitive out of CoordinatorAdapter into universal
SessionManager core primitives, replace the fragile poll + state-event
piggyback paths with first-class cluster bus event types for inline
approval delivery, and clean up the resulting frontend reducer.

Architecture
- New `turnstone/core/children_registry.py` — universal parent → children
  + reverse-lookup primitive with atomic `add_child` (returns parent UI
  for race-free dispatch). Lifted from `CoordinatorAdapter`.
- New `turnstone/core/child_source.py` — `ChildSource` Protocol with
  `SameNodeChildSource` (in-process via SessionManager state observer)
  and `ClusterChildSource` (cross-node via ClusterCollector listener).
- `SessionManager._on_state_change` upgraded to multi-subscriber
  (`subscribe_to_state` / `unsubscribe_from_state`) under a dedicated
  lock; CLI consumer migrated.
- `CoordinatorAdapter` shrunk: 731 → ~640 LOC. Children data lives in
  the registry; fan-out lives in ClusterChildSource. Backward-compat
  property facades dropped; tests updated to use the registry surface.

Cluster bus event vocabulary
- New event types `intent_verdict`, `approval_resolved`,
  `approve_request` flow through both `ClusterCollector._apply_delta`
  (translation from node SSE) and `emit_console_ws_*` (synthesis on
  console pseudo-node).
- `CoordinatorAdapter._dispatch_child_event` re-emits as
  `child_ws_intent_verdict` / `child_ws_approval_resolved` /
  `child_ws_approve_request` on the parent coord's SSE stream.
- New `_broadcast_intent_verdict` / `_broadcast_approval_resolved` /
  `_broadcast_approve_request` no-op hooks on `SessionUIBase`. WebUI
  pushes to the global queue; ConsoleCoordinatorUI pushes to the
  collector. `approve_tools` calls `_broadcast_approve_request` right
  after setting `_pending_approval` so the items reach the coord tree
  immediately, eliminating the bulk-fetch race.

Cleanups
- `pending_approval_detail` piggyback on `ws_state` / `cluster_state`
  removed end-to-end. Bulk fetch + explicit verdict / approve-request
  push are the canonical carriers.
- Browser `_judgePollTick` 90-second poll loop deleted; push path is
  authoritative.
- `urgent` flag on `scheduleLiveFetch` deleted (only caller was 409
  retry; replaced with `invalidateLiveBadge` + standard schedule).
- Console `_fetch_live_block` derives `pending_approval` from a
  disjunction (`activity_state="approval"` OR `state="attention"`
  OR detail present) so the bulk fetch can't return false during the
  state-transition race window.
- Coord-side merge guard in `flushLiveFetches` no longer clobbered:
  `handleChildState` only stamps `sseUpdatedAt` when authoritatively
  clearing detail.
- `child_locality` capability flag removed (was inert dead code).

Reliability
- Selective drop on listener queue overflow: critical event types
  (verdicts, approvals, ws_closed, child_ws_*) evict one oldest item
  to make room rather than dropping themselves on a full queue.
  Best-effort events (state ticks, content tokens, status, activity)
  drop as before. Applied to `SessionUIBase._enqueue`,
  `ClusterCollector._fanout`, and the `WebUI._global_queue` puts in
  the new broadcast hooks.
- `_state_subscribers` snapshot under a dedicated lock so concurrent
  subscribe / unsubscribe during dispatch can't shift the iterator.

UX / a11y
- Loading placeholder in renderChildRow keeps row height stable while
  the bulk fetch is in-flight (sr-friendly aria-label).
- Focus preservation across `_renderChildrenNow` (capture +
  restore by row + marker) and across targeted `_updateChildRow` swaps.
- Layout-shift transition on the approval block max-height; respects
  `prefers-reduced-motion`.
- Sidebar pending count: `(N children · M pending)`.
- Risk pill `aria-label` spells out level + confidence for SR users.
- Per-coord SSE listener queue depth surfaced in the status bar
  (`queue N/500`) with color escalation (warn at >50%, danger at >80%).

Tests
- 305+ test changes across 8 files. New unit tests for
  `ChildrenRegistry`, `ChildSource` (both impls + multi-subscriber
  observer), the new collector emit + apply_delta cases, the dispatch
  cases for new event types, the broadcast hook overrides on both
  WebUI and ConsoleCoordinatorUI, and the focus / placeholder /
  pending-count frontend assertions in `test_coordinator_page.py`.

5024 passed, ruff + mypy clean.
2026-04-30 23:20:14 -07:00
renovate[bot] 92d4602da3 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.8 2026-04-30 23:16:07 -07:00
Patrick Buckley 435289ce6c feat(console): multi-select delete UX for Saved Coordinators (#458)
* feat(console): multi-select delete UX for Saved Coordinators

Mirror the per-server "Saved Workstreams" multi-select delete onto the
console's "Saved Coordinators" section.  Coordinator deletes go through
the existing routing proxy at POST /v1/api/route/workstreams/delete
(body-keyed by ws_id, since coordinators live on the node that owns
them) — no backend change required.

Pagination caps the visible page (and therefore the Select-All fan-out)
at 24.  Without it, a Select-All on a busy cluster would pin the
console proxy pool with hundreds of parallel deletes through the
fan-out router.  While in delete mode the saved-coordinators list is
frozen against SSE re-renders so visible cards don't shuffle out from
under the user's selections (drained on cancel / post-delete close).

Refactor: shared logic now lives in turnstone/shared_static/cards.{css,js}.

  * .ws-delete-* CSS moved out of ui/static/style.css into the shared
    sheet alongside .dashboard-card; the existing ui/static modal
    markup picks up class hooks instead of id-scoped rules.
  * createSavedCardsController() owns mode state, checkbox decoration,
    toolbar wiring, focus trap, modal lifecycle, and batch fan-out.
    Both ui/static (Saved Workstreams) and console/static (Saved
    Coordinators) instantiate one controller; ui/static is now ~300
    LOC lighter as a result.
  * Internalises stale-selection prune across SSE re-renders, the
    wsId->item lookup map (was O(selected x N)), and the aria-hidden
    wrap on the toggle button's emoji glyph.

Designer review tightened the affordance:

  * Modal close restores focus to the toggle button (was landing on
    <body>) — WCAG 2.4.3.
  * Modal [role="alert"] gets a red-chip treatment when populated,
    stays invisible at rest via :not(:empty).
  * Pagination consolidated onto the existing .pagination control
    (terse "X / Y" label + arrow-glyph buttons) instead of a parallel
    .coord-pagination treatment.
  * Filled destructive buttons darkened to #dc2626 in dark theme so
    the white label clears WCAG AA contrast (was 3.0:1 on --red).
    Light theme keeps --red unchanged (5.9:1 already passes).
  * Toolbar wraps below 700px viewport — Delete Selected drops to its
    own full-width row underneath count + Cancel + Select All for
    thumb-target separation.
  * .ws-card-check:focus-visible outline + word-break on
    .ws-delete-item for narrow-modal long aliases.

* fix(cards): address Copilot review feedback on PR #458

* closeModal focus restore now falls back to the section toggle button
  (opts.buttonId) when prevFocus is hidden or detached.  The post-delete
  Close path runs cancel() before closeModal(), which puts the bar at
  display:none — so the captured prevFocus (the bar's "Delete Selected"
  button) is no longer focusable and focus would land on <body>,
  defeating the WCAG 2.4.3 fix.  Esc / Cancel paths still land on the
  original focus owner because the bar stays visible in those flows.

* Saved Coordinators onClose drains _savedCoordsRetry before reloading.
  Without it, SSE events that arrived during the delete-mode freeze
  leave the retry flag true, so loadSavedCoordinators's .finally()
  re-fires a second fetch immediately after the first resolves.  Mirrors
  the same idiom in cancelCoordDeleteMode.
2026-04-30 18:35:35 -07:00
Patrick Buckley 0e25bad94e fix(storage): address PR #457 review feedback
Three issues from the Copilot review on PR #457:

1. SQLite race in bulk_close_stale_orphans (Copilot): the SELECT-then-
   UPDATE flow doesn't re-apply the eligibility predicates on the
   UPDATE, so a row that gets touch_workstream-bumped (or set_state-
   transitioned) between the two statements would still be flipped
   to closed.  Postgres dodges this via UPDATE...RETURNING (one atomic
   statement); SQLite needs the explicit re-application.  Fix: rebuild
   the WHERE conditions list once, apply on both SELECT and UPDATE,
   then SELECT-back by ``state='closed' AND updated=now`` to get the
   accurate closed-id list.  A row that became fresh between the two
   statements skips the UPDATE entirely.

2. SQLite IN-clause bind-parameter limit (Copilot): default 999 cap
   could be exceeded on a backlog reap (e.g. after a long outage).
   Chunked the candidate id list at 500 — same chunk size
   prune_workstreams (line 453) uses for the same reason.

3. Wall-clock-dependent test asserts (Copilot, two locations): the
   tests asserted ``updated > '2024-01-01T00:00:00'`` which is fragile
   on systems with skewed clocks or pre-2024 dates.  Replaced with
   ``updated != stale_seed`` — captures the same intent (the value
   was bumped) without depending on wall-clock date.

Two ``...``-as-no-op flags from github-code-quality were false
positives — ``...`` is the standard Python idiom for Protocol method
bodies and matches every other method in _protocol.py.  No code change.
2026-04-30 13:32:05 -07:00
Patrick Buckley 0debc5d061 fix(session_manager): scope orphan reaper by services.last_heartbeat
Replaces the ``node_id == self_node_id`` orphan-scoping heuristic from
earlier on this branch with liveness-based scoping using
``services.last_heartbeat``.  The heuristic was wrong for the post-#384
world: PR #384 (refactor: replace hash-ring rebalancer with rendezvous
hashing) deleted the rebalancer that used to keep workstreams.node_id
pointing at a live node.  Without it, ``workstreams.node_id`` is now
stamped at create time and never updated, so in containerized
deployments with dynamic hostnames a dead pod's rows have ``node_id``
matching no surviving service — they'd accumulate forever under the old
heuristic.

services.last_heartbeat is the same primitive the rendezvous router
uses for routing.  Reusing it here keeps reap scoping aligned with
routing: dead pods' rows fall out of the live set after the heartbeat
window and become reapable; alive pods' rows stay protected as long as
they heartbeat.

Mechanics:

- ``bulk_close_stale_orphans`` parameter renamed
  ``node_id: str | None`` → ``live_node_ids: list[str] | None``.  The
  WHERE clause becomes ``(node_id IS NULL OR node_id NOT IN
  live_node_ids)``.  ``None`` skips the filter entirely (single-process
  / tests / operator backfill).  ``[]`` treats every row as
  unprotected.
- ``SessionManager.close_idle`` pass 2 calls
  ``storage.list_services(self._service_type)`` to enumerate live
  peers, passes their service_ids as ``live_node_ids``.  ``_service_type``
  is derived from ``self.kind`` (INTERACTIVE→"server",
  COORDINATOR→"console") via a module-level mapping — no constructor
  param, so production wiring can't miswire the kind/service_type
  pairing.
- list_services failure → pass 2 is skipped this tick (conservative;
  never reap when liveness state is unknown).  Pass 1 still runs.
- ``workstreams.node_id`` with NULL value is always eligible — defends
  against ANSI ``NULL NOT IN (...)`` evaluating to NULL (not TRUE) and
  silently protecting orphans forever.
- Migration 048 simplified to ``(kind, updated)``; the new query's
  ``NOT IN (small list)`` predicate against an unbounded-cardinality
  column doesn't index well, so leading ``node_id`` would just add
  write cost.

Tests cover the live-services protection (own/dead/null cases), the
empty-peers reap-all case, the list_services-failure conservative
fallback, both kind/service_type pairings (interactive→"server",
coordinator→"console"), and the combined live_node_ids +
exclude_ws_ids filter matrix.
2026-04-30 13:32:05 -07:00
Patrick Buckley 58975ba02a perf(storage): partial composite index for the orphan reaper query
bulk_close_stale_orphans runs every min(300s, idle_timeout/4) on
every server and console process.  Its WHERE shape is:

    WHERE kind = ?
      AND state IN ('idle','thinking','attention','running')
      AND updated < ?
      AND node_id = ?           -- multi-node interactive only

At current scale the existing single-column indexes are sufficient —
idx_workstreams_state prunes to non-closed and the planner filters the
rest sequentially.  At 100k+ rows that filter becomes a tablescan-
shaped cost.

A partial index covering only BULK_CLOSE_STATE_VALUES rows matches the
reaper's query exactly while staying tiny — closed rows (typically
95%+ of the table) and error rows are excluded, so the index is
roughly 5% the size a full multi-column index would be.  Write
amplification only kicks in for transitions touching one of the four
covered states.

Column order (node_id, kind, updated): node_id is the most selective
filter for multi-node interactive (each server prunes to its own
node's rows), kind second so coord-only and interactive-only queries
within a node still get index-only scans, updated last so the range
comparison rides the trailing column.

Postgres uses CREATE INDEX CONCURRENTLY so the build is non-blocking
on a live system; SQLite has no concurrent concept and the table-
level write lock already serializes, so a plain CREATE INDEX is fine.
2026-04-30 13:32:05 -07:00
Patrick Buckley bf17b0511e fix(console): periodic idle cleanup for the coordinator pool
The console's coord SessionManager had no idle thread — close_idle was
never called for coordinator workstreams.  This is the worse half of
the lifecycle leak: the dashboard filters via the in-memory pool, so
DB-only orphan coords were invisible.  At empirical diagnosis,
coord closure was 16% (10 closed / 64 total) vs interactive 63%.

Adds _coord_idle_cleanup_thread mirroring turnstone/server.py's
_idle_cleanup_thread but skipping the rate-limiter / global-queue arms
the console doesn't have.  Started from the lifespan when coord_mgr is
constructed and server.workstream_idle_timeout > 0 (reuses the
existing setting — same cadence works for both kinds).

Initial sweep runs INSIDE the thread before the first sleep, not
synchronously in the lifespan: cold-start orphans are reaped without
blocking Starlette boot.  Important because cold start with many DB
orphans (the precise condition this code targets) is exactly when the
UPDATE is most likely to be slow.

Helper takes an optional stop_event parameter purely for tests —
production callers pass None and the daemon runs for process lifetime.
This avoids the SystemExit-from-stub + module-wide filterwarnings
fragility a previous iteration relied on.

Four tests: initial sweep runs before first sleep, ticks fire each
loop, exceptions don't kill the thread, stop_event exits cleanly.
2026-04-30 13:32:05 -07:00
Patrick Buckley 1405afe079 fix(session_manager): close DB-orphan workstreams in close_idle
Real bug: workstream rows accumulate in non-closed states (idle,
thinking, attention, running) when their owning process restarts or
crashes.  Empirical diagnosis on a live deployment found ~60 stuck
coord rows in DB invisible to the in-memory-keyed dashboard, plus
100+ interactive rows older than the 2h timeout (one stuck "thinking"
for 2 weeks — impossible across a process restart).

Root cause: close_idle iterates self._workstreams.values() — only the
loaded subset.  Anything left behind by a prior process incarnation
sits in DB forever because nothing ever re-loads it.

This commit gives close_idle a second pass.

Pass 1 (existing, unchanged): close loaded IDLE rows whose
ws.last_active (monotonic) is past timeout.  IDLE-only so legitimately-
attentive rows (waiting for user response) stay live.

Pass 2 (new): bulk-close DB rows of this manager's kind whose updated
is past the wall-clock cutoff and which aren't currently loaded.
Closes the broader BULK_CLOSE_STATE_VALUES set — any matching row is
by definition not loaded by any process and cannot be in a live
interaction.  Scoped by self._node_id so a sibling node can't reap
rows we own (multi-node interactive correctness).  No emit_closed —
never-loaded rows have no SSE listeners expecting them.

Lock invariant: pass 1 holds self._lock briefly to snapshot victims
and pop them (existing behavior).  Pass 2 holds self._lock briefly to
snapshot the loaded keys, then releases before the DB UPDATE so a slow
reaper query can't block create/get/set_state.

Also fixes a same-process race in open(): the rehydrate path read DB,
released the manager lock, then re-acquired to install — a concurrent
pass 2 between the two acquisitions snapshots loaded keys without the
in-flight ws_id, and could clobber its DB row to closed.  open() now
calls touch_workstream(ws_id) on rehydrate so the row's updated is
fresh against any pass-2 cutoff.  Pure timestamp write is safe against
concurrent close() (close still wins on the state column).

Three new tests cover the DB orphan pass (basic, exclude-loaded, kind
filter) plus node_id scoping (own/foreign rows, None-skips-filter) and
the open() rehydrate touch.
2026-04-30 13:32:05 -07:00
Patrick Buckley fff7840de7 fix(storage): add bulk_close_stale_orphans + touch_workstream primitives
Two new methods on the StorageBackend Protocol, with implementations on
both Postgres (UPDATE ... RETURNING) and SQLite (SELECT-then-UPDATE in
one transaction).  No callers yet — wiring lands in subsequent commits.

bulk_close_stale_orphans(kind, cutoff, exclude_ws_ids, node_id=None)
flips rows in BULK_CLOSE_STATE_VALUES (idle/thinking/attention/running)
to closed when their updated timestamp is lex-older than cutoff.  The
node_id filter scopes the reap to a single node's partition — required
for multi-node interactive deployments where each node only has
authority over its own workstreams.node_id rows.  Excludes loaded ids
so the in-memory pass owns those.

touch_workstream(ws_id) bumps updated without changing state.  Used by
the open() rehydrate path to defend against the orphan reaper clobbering
a freshly-loaded row whose DB updated is older than the cutoff.  Pure
timestamp write is safe against concurrent close() because close still
wins on the state column.

BULK_CLOSE_STATE_VALUES is centralized in workstream.py so the two
backend implementations and FakeStorage all agree; if a new transient
state is added to WorkstreamState, deciding whether it joins this set
is part of the change rather than an after-the-fact audit across three
files.

Storage tests (run against both backends via the conftest fixture) cover
the kind/state/cutoff/exclude/node_id matrix plus touch_workstream.
2026-04-30 13:32:05 -07:00
Patrick Buckley 7a36ab95e4 fix(metacog): drop duplicate [repeat: tool()] info line
The themed ``tool_reminder`` bubble below the tool block already
shows the metacog text, and the tool block immediately above it
carries the tool name — so a separate gray ``[repeat: list_workstreams()
called with same arguments]`` info line was just duplicate visual
noise (operator-visible in the screenshot below the bubble).

Drop the ``ui.on_info`` call inside ``_apply_post_execute_advisories``
that emitted the diagnostic line.  Update the docstring to reflect
that the bubble is the canonical signal.  Rename
``test_emit_repeat_ui_line_on_streak_fire`` →
``test_no_legacy_repeat_info_line_on_streak_fire`` and invert the
assertion.
2026-04-30 03:13:37 -07:00
Patrick Buckley b07d7f19b6 fix(cli): add on_user_reminder + on_tool_reminder to TerminalUI
CI typecheck failed because ``WorkstreamTerminalUI(TerminalUI)``
inherits from ``SessionUI`` (the Protocol), and the Protocol's
``on_user_reminder`` / ``on_tool_reminder`` declarations have empty
bodies — mypy treats those as implicitly abstract, so the subclass
became un-instantiable.

Add real implementations on ``TerminalUI`` that render reminders as
``[metacognition · type] text`` lines in yellow.  This also restores
the metacog signal on the CLI surface (the legacy
``[metacognition: nudge injected — …]`` info-line went away with
``_emit_nudge_ping``; without this commit the CLI showed no signal
at all for metacog nudges).  Tool-channel and user-channel render
identically because terminal output is anchored by stdout flow
rather than by DOM anchor — the line lands directly after the
message it advises.
2026-04-30 03:13:37 -07:00
Patrick Buckley 5bd5593f95 docs(metacog): align comments with side-channel + tool-channel scope
Address Copilot's review feedback on PR #456 — the docstrings and
inline comments hadn't all caught up with the architectural shift
across the branch:

  - ``_apply_reminders_for_provider`` docstring: "every user message"
    → role-agnostic, since tool messages also carry ``_reminders``
    (tool_error / repeat).
  - ``_mark_reminders_delivered`` docstring: same role-agnostic
    update; explicitly note both channels.
  - ``_append_user_turn`` callsite comment near
    ``_attach_pending_user_reminders``: still described splicing
    ``<system-reminder>`` blocks into user content; updated to
    reflect the side-channel attach + transient-copy splice at the
    provider boundary.
  - ``_build_history`` block comment: was user-message-only; now
    mentions tool messages and both ``user_reminder`` /
    ``tool_reminder`` SSE events.
  - ``_build_history`` propagation comment: same role-agnostic note
    on the per-entry surface.
  - ``app.js`` ``user_reminder`` SSE handler comment: said the
    bubble renders "above" the user message, but
    ``insertAdjacentElement('afterend', el)`` drops it BELOW.
  - ``app.js`` ``replayHistory`` comment: said "insertBefore drops
    the reminder directly above the just-rendered user bubble";
    same fix — bubble lands BELOW.

No behaviour change.
2026-04-30 03:13:37 -07:00
Patrick Buckley 845dbab616 fix(metacog): drop write-success-clear so sequential same-call streaks fire
The repeat-detection block in ``_apply_post_execute_advisories`` had
a leftover "clear streak when a write tool succeeded" branch from
when ``RepeatDetector`` tracked cumulative counts.  With the
consecutive-streak semantics introduced earlier in the branch the
branch became:

  1. Redundant — any different (name, args) signature already resets
     the streak via ``RepeatDetector.record``, so an intervening
     read/write naturally breaks the streak.
  2. Actively wrong — the clear runs ONCE at the top of each
     ``_apply_post_execute_advisories`` call, before the per-result
     loop records sigs.  In a single parallel batch
     ``[bash, bash, bash]`` the clear runs once and then three
     ``record`` calls accumulate to count=3 in the same call → fires.
     But across three sequential turns, each turn calls
     ``_apply_post_execute_advisories`` fresh, the clear runs at the
     top of each call, and only one ``record`` per call follows — so
     the count never gets above 1 and the canonical
     "small local model stuck on ``bash('echo test')``" pattern
     never triggered the nudge.

The asymmetry only existed for successful calls — failures don't
satisfy the ``not _tool_error_flags.get(tc["id"])`` predicate, so
the clear didn't fire and sequential failures already worked.  The
fix is to drop the clear entirely; ``RepeatDetector``'s
consecutive-streak semantics handle every case uniformly.

Tests:

  - ``test_successful_write_clears_streak`` →
    ``test_intervening_different_call_resets_streak`` —
    rewords the assertion to reflect the actual mechanism (any
    different sig resets, write-or-otherwise) since "writes clear"
    was the bug, not the contract.
  - ``test_failed_write_does_not_clear_streak`` →
    ``test_sequential_bash_failures_fire_repeat`` — same shape, just
    framing fixed.
  - New ``test_sequential_bash_same_command_fires_repeat`` —
    regression for the bug user hit (three sequential successful
    ``bash('echo test')`` calls now correctly fire the nudge).
2026-04-30 03:13:37 -07:00
Patrick Buckley c0fd951764 feat(metacog): themed reminder bubble unifies user + tool channels
The yellow themed reminder card introduced for user-channel nudges
(correction / denial / resume / start / completion) now also fronts
tool-channel nudges (tool_error / repeat).  Pre-fix the tool channel
shipped its reminders inside the tool-result envelope via
``wrap_tool_result``, leaking the ``<system-reminder>`` block into
``self.messages`` content (same problem the user channel had before
the side-channel refactor) and surfacing the legacy gray
``[metacognition: nudge injected — …]`` info line as the only
operator-visible signal — duplicated alongside the new themed bubble
for user-channel nudges.

Tool-channel parity:

  - ``_collect_advisories`` now returns
    ``(persistent_advisories, metacog_reminders)``.  Persistent
    advisories (``GuardAdvisory`` / ``UserInterjection``) keep
    riding ``wrap_tool_result`` because they ARE conversation
    history.  Metacognitive reminders extract to the second tuple
    element; the caller attaches them to the tool message dict's
    ``_reminders`` side-channel and emits ``on_tool_reminder``.
  - ``_apply_reminders_for_provider`` already handles ``_reminders``
    on any role, so the tool-channel splice into wire content is
    free.  ``_build_history`` also already propagates
    ``entry["reminders"]`` regardless of role, so reload renders the
    bubble too.
  - ``SessionUI`` Protocol gains ``on_tool_reminder(reminders,
    tool_call_id)``; ``SessionUIBase`` enqueues a ``tool_reminder``
    SSE event with the ``tool_call_id`` anchor.
  - ``_emit_nudge_ping`` had no remaining callers and was removed —
    the themed bubble (live SSE + ``/history`` reload) is the
    canonical operator signal for both channels now.

UI polish (the four fixes the screenshot caught for the user
channel + their tool-channel mirror):

  - Bubble renders BELOW the message it advises (semantically: a
    hint to the model right before its turn).  ``addUserReminder``
    swaps ``insertBefore`` for ``insertAdjacentElement('afterend',
    el)``; ``addToolReminder`` anchors below the ``.ts-approval``
    block whose tool result triggered the batch's reminder.
  - Label uses the full feature name ``metacognition`` (was the
    ``metacog`` shorthand).
  - Card width / alignment inherits from the base ``.msg`` rule —
    ``align-self: flex-end`` and the explicit ``max-width`` are
    gone, so the card matches the user / assistant column instead
    of pinning right-aligned narrow.
  - The legacy ``[metacognition: nudge injected — …]`` gray info
    line is gone for both channels.

Frontend additions:

  - ``Pane.prototype.addToolReminder(reminders, toolCallId)``
    anchors below the ``.ts-approval`` block (live: by
    ``data-call-id``; replay: by "last block in messagesEl"
    fallback, which is correct because messages render in order).
  - SSE switch case ``"tool_reminder"`` calls ``addToolReminder``.
  - ``replayHistory``'s tool-message branch now calls
    ``addToolReminder`` when ``msg.reminders`` is present.
  - ``addUserReminder`` advances its anchor on each loop iteration
    so multiple reminders stack in queued order rather than
    reversed.

Coord console parity:

  - ``coordinator.js`` gains ``appendReminderBubble`` /
    ``appendUserReminderLive`` / ``appendToolReminderLive`` mirroring
    the interactive UI.  The tool-channel anchor walks
    ``toolRows[callId].batch`` to attach below the
    ``.coord-tool-batch`` construct (one bubble per dispatch turn,
    matching the "one nudge per batch even with many failing tools"
    drain).
  - SSE switch handles ``user_reminder`` and ``tool_reminder`` on
    the coord conversation surface.
  - ``/history`` replay propagates ``msg.reminders`` for user and
    tool messages — same wire shape as the interactive pane.
  - ``.msg.user-reminder`` styles moved to
    ``shared_static/chat.css`` so both surfaces inherit the same
    yellow themed bubble from the shared base.

Defensive read on ``_apply_reminders_for_provider`` (per Copilot
review on the closed PR): a malformed ``_reminders`` entry (string,
None, etc. — corruption / partial state) used to abort ``send`` via
AttributeError on the ``.get("text", "")`` call.  Filter to dicts
before building the block, mirroring the same filter
``_build_history`` already applies on the wire-out side; an
all-malformed list passes through as no-reminders.

Tests:

  - ``test_collect_advisories_drains_tool_buffer_on_last_result``
    rewritten to assert the ``(persistent, metacog)`` tuple shape
    and that ``MetacognitiveAdvisory`` no longer appears in the
    persistent list.
  - ``test_collect_advisories_holds_*`` and ``_drops_*`` updated for
    tuple return.
  - ``test_attach_emits_visibility_ping`` /
    ``test_collect_advisories_emits_visibility_ping`` inverted to
    assert the legacy gray line is gone on both channels.
  - ``TestSessionUIBaseToolReminderHook`` covers the new SSE event
    shape with the ``tool_call_id`` anchor.
  - ``test_malformed_reminders_filtered_out`` and
    ``test_all_malformed_reminders_passes_through`` cover the
    Copilot-flagged defensive filter.
2026-04-30 03:13:37 -07:00
Patrick Buckley 3aa9f53fd8 fix(session): metacog reminders ride a side-channel, not user content
User-channel metacognitive nudges (correction, denial, resume, start,
completion) used to be spliced into ``user_msg["content"]`` permanently,
which leaked the ``<system-reminder>`` envelope into every consumer of
``self.messages`` — UI replay (mitigated by a regex strip in /history),
compaction, title generation, and any future channel adapter that
echoes conversation context.  The /history strip was a band-aid;
compaction and title-gen still saw the raw spliced text.

Switch to a side-channel: ``_attach_pending_user_reminders`` writes the
rendered reminder list to ``user_msg["_reminders"]`` (sibling key,
leading-underscore convention shared with ``_attachments_meta`` /
``_provider_content``).  At the provider boundary, a new
``_apply_reminders_for_provider`` builds a transient shallow-copy with
the reminder spliced into ``content``; the original message dict
stays clean.  ``sanitize_messages`` drops the sibling key on the wire.

Once-per-session-not-per-turn semantics for the wire: after stream
success the loop calls ``_mark_reminders_delivered``, which flips a
``_reminders_delivered`` flag on every user message that carried
reminders into that call.  ``_apply_reminders_for_provider`` skips
already-delivered messages so the model sees each reminder exactly
once (the turn it advised).  ``_build_history`` ignores the delivered
flag entirely, so reconnecting tabs render the same nudge bubble the
originating tab saw via the live ``user_reminder`` SSE event.

UI surface:

  - ``SessionUIBase.on_user_reminder`` enqueues a
    ``{type: "user_reminder", reminders: [...]}`` SSE event with the
    same shape ``_build_history`` surfaces.
  - ``app.js`` renders a ``.msg.user-reminder`` bubble (yellow accent,
    pill-styled) anchored above the user message it advises, both
    live and on history replay.
  - ``replayHistory`` renders ``addUserMessage`` before
    ``addUserReminder`` so the anchor lookup finds the just-rendered
    turn (not a prior one).
  - Multi-tab caveat documented inline: non-originating tabs receive
    no ``user_message`` SSE event today, so a reminder may anchor to
    a stale prior bubble until ``/history`` reload corrects it.

Pre-existing bug surfaced by the audit: cancel handlers
(``GenerationCancelled`` / ``KeyboardInterrupt`` / generic
``Exception``) in ``ChatSession.send`` cleared
``_pending_tool_advisories`` but not the user-channel buffer.  Both
now drain through a shared ``_drain_pending_advisories`` helper.

Removed the ``/history`` regex strip — the side-channel approach
makes it redundant.  Hoisted ``escape_wrapper_tags`` +
``render_system_reminder`` imports to module top (called 2-3× per
turn).

Tests:

  - ``TestApplyRemindersForProvider`` — pass-through-by-reference,
    string + list content splice, escape on user-typed wrapper tags,
    multi-reminder ordering, source-untouched invariant, delivered
    flag skip path, fallback for unexpected content shape.
  - ``TestMarkRemindersDelivered`` — flag idempotency, no-reminders
    no-flag, only marks user messages with reminders.
  - ``TestUpdateTokenTableMsgsParam`` — calibration uses pre-built
    msgs when provided, falls back when not.
  - ``TestUserAdvisoryCancelClear`` — all three cancel branches drain
    the user buffer.
  - ``TestReminderSidechannelIsolation`` — compaction's
    ``_format_messages_for_summary`` and the title-gen extraction
    loop cannot see reminders by construction.
  - ``TestSessionUIBaseUserReminderHook`` — ``on_user_reminder``
    enqueues the right SSE shape.
  - ``TestBuildHistoryReminderPropagation`` — ``entry["reminders"]``
    propagation, absent / empty / multi / coexist-with-attachments
    cases, malformed input filtering, all-malformed elision.
  - ``test_sanitize_messages_strips_underscore_sibling_keys`` covers
    ``_reminders`` and ``_reminders_delivered``.
2026-04-30 03:13:37 -07:00
Patrick Buckley 7c8cb8c595 fix(metacog): N>=3 streak detector + drop redundant error-prefix list
Cleanup pass on the metacognitive nudge stack — restores pre-split
errored-counts-toward-repeat behaviour and tightens the is_error
plumbing through the per-batch advisory hook.

The per-batch hook in ``_run_loop`` was duplicating the is_error
signal: ``self._tool_error_flags`` (set by ``_report_tool_result``)
and a string-prefix tuple (``Error`` / ``JSON parse error`` / …).
Two truth sources is what got us here — bash commands that exit
non-zero with normal stdout matched the flag but not the prefix,
the deny path matched the prefix but not the flag, and the result
was that stuck-loop detection silently broke for the most common
failure mode (the model bashing the same broken command).

Single source of truth now:

- ``_execute_tools.run_one`` deny branch routes through
  ``_report_tool_result(is_error=True)`` so denied calls populate
  ``_tool_error_flags`` like every other error path.
- The error-prefix tuple is gone; the write-success-clear gate and
  the tool-error-nudge gate both read ``_tool_error_flags`` only.

Repeat-detection state moves from a ``set[str]`` (fired on the second
identical call, ignored errors entirely) to a ``RepeatDetector``
helper in ``metacognition.py`` with consecutive-streak semantics:

- Threshold raised from 2 to 3 — two-in-a-row was noisy on
  legitimate transient retries; three is the cheapest stuck-loop
  signal.
- Recording a different signature resets the count, so [A, A, B, A]
  is two short streaks of 2 and not a streak of 4. Bounded by O(1)
  state regardless of session length.
- Errored calls now count toward the streak (the split into a
  separate metacog module unintentionally introduced a "skip errors"
  branch — restored).

While there:

- ``metacognition._COOLDOWN_SECS`` default aligned to 300s (matches
  ``MemoryConfig.nudge_cooldown`` and the ``memory.nudge_cooldown``
  config-store default; was set to 30 by an earlier investigation).
- The per-batch advisory block (~80 lines of mixed orchestration
  inside ``_run_loop``) is extracted to
  ``ChatSession._apply_post_execute_advisories`` so the wired
  behaviour is testable without driving ``_run_loop`` end-to-end.
  Producer extraction to a dedicated module is deferred to a
  follow-up; advisory producers all live on ``ChatSession`` for
  now per existing convention.
- Frontend ``appendToolOutput`` (turnstone/ui/static/app.js) now
  skips rendering when the parent approval block is denied or
  the output starts with ``Denied by user`` / ``Blocked``,
  mirroring the history-replay guard at ``_build_history``.
  Previously the live SSE path didn't need this guard because
  the deny path never emitted a ``tool_result`` event; the
  is_error routing change above means it does now, so without
  this guard the badge from ``resolveApproval`` and the SSE
  output would both render.

Tests: 8 unit tests for ``RepeatDetector`` covering streak,
threshold, clear, and intervening-sig reset; 9 integration tests
for ``_apply_post_execute_advisories`` covering the wired
behaviour (3-identical fires warning + advisory + UI line, errored
calls count toward streak as a regression guard, intervening sig
resets streak, successful write clears, failed write does not,
JSON outputs tracked but not inline-warned, tool_error nudge gates
on memory_count, repeat UI line emitted on streak fire).
2026-04-30 03:13:37 -07:00
Patrick Buckley c910b0fdff docs(coord): tighten handleChildState comment per Copilot review
The pre-existing comment said pending_approval_detail "rides on
every ws_state event" — that overstated the case.  The node-side
emit is gated on ``_pending_approval is not None`` so the field is
absent on the steady-state broadcast and possibly null on a node
mid-rolling-upgrade.  The handleChildState fallback already
handles both cases; only the comment was wrong.
2026-04-29 19:52:44 -07:00
Patrick Buckley 88facd260e feat(coord): pass pending_approval_detail on child_ws_state SSE events
Inline child approve/deny in the coord tree UI was rendering downstream
of the bulk-live cache (``GET /v1/api/cluster/ws/live``), not the SSE
stream. ``child_ws_state`` events were tiny notifications that fired
an urgent live-bulk fetch on every activity_state transition into/out
of "approval", just to pick up the rich ``pending_approval_detail``
payload. With multiple coord tabs and multi-child workstreams, that
urgent-fetch pattern compounded the SSE-executor pressure Shape A
is unwinding.

Thread the field through every layer so the SSE event itself carries
the rich payload — browser mutates ``liveBadgeCache`` directly,
no urgent fetch:

  1. Node ``WebUI._broadcast_state`` emits ``pending_approval_detail``
     on ``ws_state`` events. Gated on ``_pending_approval is not None``
     so the per-broadcast verdict-cache deepcopy only runs when there
     is actually an approval pending. ``_build_node_snapshot`` also
     projects the field so the console's reconnect-via-snapshot
     resync path delivers it (without this the new collector
     forwarding would never see the field on a snapshot row).

  2. Console ``ClusterCollector._apply_delta`` (live ``ws_state``
     forwarding) and ``_reconcile_node`` (snapshot resync diff) both
     forward the field on the emitted ``cluster_state`` event, AND
     ``_apply_delta`` persists it on the cached ``ws`` dict so the
     ``get_node_detail`` / ``get_snapshot`` endpoints between
     reconciliations don't render stale approve/deny buttons.

  3. ``CoordinatorAdapter._dispatch_child_event`` re-emits the field
     on the ``child_ws_state`` event sent to coord listener queues.

  4. Frontend ``handleChildState`` reads ``ev.pending_approval_detail``
     and writes it directly into ``liveBadgeCache``, tagging the
     entry with ``sseUpdatedAt``. ``flushLiveFetches`` honors that
     tag for ``SSE_AUTHORITATIVE_MS`` (3s) — the upstream
     ``/dashboard`` cache has its own ~2s TTL, so a bulk-poll
     landing right after a transition can otherwise clobber the
     fresh SSE-set state with pre-transition data.

The pre-fix ``enteredApproval`` / ``leftApproval`` urgent-fetch
branch is removed. The 409 stale-call_id retry path keeps its own
urgent fetch — that's a different scenario.

Tests cover the forwarding contract at every layer, the broadcast
gate (event includes the field when an approval is pending,
omits it otherwise, and clears after resolution), and the
``flushLiveFetches`` merge-guard structural shape so a refactor
that keeps the symbols but inverts the comparison or drops the
``prev.live`` check can't pass silently.
2026-04-29 19:52:44 -07:00
Patrick Buckley d11b2247fd fix(console): offload sync DB calls in coord children/tasks handlers
``coordinator_children`` was calling ``storage.list_workstreams``
directly on the event loop, ``coordinator_tasks`` did the same with
``load_task_envelope``, and ``_resolve_coordinator_or_404`` (called
from both handlers, plus ``coordinator_history`` and
``_resolve_coord_session``) did the same with
``storage.get_workstream`` on its cold-cache path.

The cold-cache resolver path is hit on every console restart,
coordinator eviction, and console proxy hop — exactly when the
event loop is most contended. Three coord tabs reconnecting after a
brief network blip = three serial event-loop blocks per call site.
Other lifted handlers in this file already use
``asyncio.to_thread``; bring all four call sites onto the same
pattern.

Convert ``_resolve_coordinator_or_404`` to ``async def`` and update
its four call sites to ``await``. Exception flow is unchanged.
2026-04-29 19:52:44 -07:00
Patrick Buckley a0be3e0110 fix(console): isolate coord SSE polling on a dedicated 200-thread pool
Each coord ``events`` SSE listener parks a thread on
``client_queue.get(timeout=5)`` for the connection lifetime. The
console's coord endpoint was wiring no ``sse_executor_lookup`` on
``coord_endpoint_config``, so those parks landed on Python's default
ThreadPoolExecutor (~min(32, cpu_count+4)) and competed with every
other ``asyncio.to_thread`` caller (storage, router, audit). A few
coord tabs against a multi-child workstream would stall new request
handlers waiting for a worker thread.

Mirror the interactive-side precedent (the ``sse_executor`` /
``sse_executor_lookup`` pattern in ``turnstone/server.py``) — build a
dedicated 200-thread ``coord_sse_executor`` in the console lifespan
and wire ``sse_executor_lookup`` onto ``coord_endpoint_config``.
Drain order matters: shut the pool down AFTER ``coord_adapter.shutdown()``
so no new listeners arrive at a dying pool. ``cancel_futures=True``
discards queued-but-not-started futures during teardown.

Update the stale comment on the interactive-side wiring that claimed
"coord wires None and falls back to the default executor" — it now
points at the console's matching wire.
2026-04-29 19:52:44 -07:00
Patrick Buckley 8aef377a57 fix(coord): tighten coord_registry refresh logging + comments per round-2 review
Three follow-ups from Copilot's round-2 review on #453.

ValueError logging surfaced the wrong reason
The catch-all ``except ValueError:`` logged ``reason=no_enabled_rows``
unconditionally, but ``ModelRegistry.__init__`` raises ValueError for
five distinct config issues (empty models, default / fallback / agent /
plan / task alias not present).  Operator looking at logs for a
config.toml typo would see the wrong cause.  Switch to
``log.warning("...reason=%s", exc)`` so the actual error message
threads through.  Behavior unchanged — existing registry still
preserved on every ValueError path.

Misleading shutdown() comment
The ``finally`` comment claimed shutdown() was closing clients the
throwaway registry created during DB load.  ``load_model_registry`` only
constructs ModelConfigs and the bare ``ModelRegistry(...)``;
``ModelRegistry.__init__`` leaves ``_clients`` / ``_providers`` empty
and they populate lazily on first resolve.  Today shutdown() iterates
empty dicts.  Comment now says so explicitly while keeping the call
(and its try/except) for forward-compat against an eager-init future.

Stale "probe" wording in test docstring
``test_helper_preserves_registry_when_db_probe_fails`` →
``test_helper_preserves_registry_when_strict_load_fails``.  The
explicit probe was removed in commit 1ba17ed when the helper switched
to ``load_model_registry(..., strict=True)``; the test name and
docstring still talked about a probe.  Updated wording reflects that
the loader's strict-mode re-raise is what the helper catches now.

132 tests pass.
2026-04-29 18:25:40 -07:00
Patrick Buckley e3f2237c36 refactor(coord): hygiene pass on coord_registry refresh — async + selective teardown + test cleanup
Hygiene follow-ups from the multi-stage code review on #453.

perf-1 — sync helper called from async route handlers
``_refresh_coord_registry`` runs two sync DB reads and a registry reload
that takes ``_client_lock``; calling it directly from an async handler
held the event loop for the duration.  All four call sites now
``await asyncio.to_thread(_refresh_coord_registry, ...)``, matching the
pattern from commit ``1f7d6ad`` (offloaded ``tenant_check``).

perf-3 — ModelRegistry.reload() tore down all clients unconditionally
The reload always closed every cached client and provider, even when
the changed fields (``model``, ``temperature``, ``context_window``)
didn't touch the connection target.  Now selective: clients drop only
when alias removed or ``(base_url, api_key, provider)`` differs;
providers drop only when alias removed or ``provider`` string differs.
Keeps connection pools warm across the common admin-edit case where
only metadata changed.  Two new ``test_model_registry`` cases lock the
keep-warm vs drop-on-change behaviour, and the existing
``test_reload_clears_clients`` was updated (it asserted the old
overly-aggressive contract) into
``test_reload_keeps_clients_when_connection_target_unchanged``.

q-5 — helper rename
``_refresh_console_coord_registry`` → ``_refresh_coord_registry``.  The
``console_`` prefix was redundant given the function lives in
``turnstone/console/server.py`` and sibling helpers there
(``_notify_nodes_model_reload``, ``_publish_config_change``,
``_collect_model_status``) all omit it.

q-1 — shared test middleware
``tests/test_admin_model_registry_refresh`` now imports the
header-driven ``_AuthMiddleware`` from ``tests/_coord_test_helpers``
and sets default ``X-Test-User`` / ``X-Test-Perms`` headers on the
``TestClient``.  The local hardcoded variant duplicated infrastructure
the helper module exists to centralise.

q-3 — multi-alias test registry
``_make_registry`` extracted a ``_make_config`` helper and gained an
``extras={alias: model}`` param so multi-alias scenarios stop
hand-building ``ModelConfig`` literals.
``test_delete_endpoint_refreshes_registry`` now uses the helper.

310 tests pass across the related coordinator + model surfaces.
2026-04-29 18:25:40 -07:00
Patrick Buckley 70eb50ccb7 test(coord): lock the empty-body gate with a refresh-call spy
bug-3 / q-2 from the multi-stage review on #453: the previous test
``test_update_endpoint_with_empty_body_does_not_blow_up`` asserted only
that the registry's model name was unchanged after an empty PUT, which
holds whether or not the refresh ran (DB row matches registry → refresh
is idempotent).  A regression that always called
``_refresh_console_coord_registry`` — exactly the gate this test was
meant to lock — would have left the assertion green.

Rename to ``test_update_endpoint_skips_refresh_on_empty_body`` and spy
on the helper via ``monkeypatch.setattr``.  Empty-body PUT must register
zero calls; any future change that drops the ``if updates:`` gate now
fails loudly.
2026-04-29 18:25:40 -07:00
Patrick Buckley 3b66f25506 fix(coord): strict-mode loader + guarded shutdown for coord_registry refresh
Two correctness follow-ups from the multi-stage code review on #453.

bug-2 / perf-2 (DB probe was theatre + double scan)
The previous probe defended nothing the loader didn't already swallow
on the next line: ``load_model_registry``'s row-loop catches Exception
internally, so a transient DB error after the probe still degrades to
a config.toml-only registry that ``existing.reload()`` would apply,
silently dropping every DB-sourced alias.  And on the happy path each
CRUD paid for two scans of ``model_definitions``.

Add a ``strict: bool = False`` flag to ``load_model_registry``.  When
strict, the row-loop's except re-raises instead of swallowing.  The
helper passes ``strict=True`` and drops the probe — single DB scan,
real failure isolation, the loader's silent fallback can no longer
mask a partial-result regression.  Default ``strict=False`` so CLI /
lifespan callers keep their boot-with-config-fallback behaviour.

bug-1 (shutdown could escape after a successful reload)
``ModelRegistry.shutdown()`` calls ``client.close()`` unguarded, and the
helper's ``finally`` block ran it outside the try/except.  A raising
close() after a successful ``existing.reload()`` would surface as 500
with the registry already mutated and the audit row already recording
success.  Wrap ``new_registry.shutdown()`` in its own try/except that
matches the helper's belt-and-suspenders error policy elsewhere.

The helper's docstring also drops the obsolete probe paragraph; the
``if existing is None: return`` branch gets a one-line inline comment
about the boot-from-empty case (the multi-paragraph version restated
behaviour the line itself documents).

129 tests pass (test_admin_model_registry_refresh + test_model_registry).
2026-04-29 18:25:40 -07:00
Patrick Buckley 6fc2806315 fix(coord): tighten coord_registry refresh — DB probe + accurate boot-from-empty docstring
Two follow-ups from Copilot review of #453:

1. ``load_model_registry`` swallows storage read errors internally
   (logs + continues with config.toml-only models).  Without a strict
   probe in the helper, a transient DB outage on an admin CRUD would
   apply a truncated registry that drops every DB-sourced alias —
   silently, since the loader returns a non-empty registry built from
   ``[models.*]`` config.toml entries.  Add an explicit
   ``storage.list_model_definitions(enabled_only=True)`` probe before
   the loader call so the failure is visible here and the existing
   registry is preserved on outage.

2. The previous docstring claimed ``admin_model_reload`` "has its own
   boot-from-empty story."  It doesn't — it just calls this helper,
   which no-ops when ``coord_registry`` is None.  When no model rows
   existed at boot, lifespan leaves the entire coord subsystem
   uninitialized (no ``coord_mgr``, no ``coord_adapter``, no
   ``session_factory``), and a console restart remains required after
   the operator adds the first row.  Tighten the docstring to admit
   that limitation rather than overstating the helper's reach.

New test ``test_helper_preserves_registry_when_db_probe_fails``
monkeypatches ``list_model_definitions`` to raise and asserts the
existing registry stays intact.
2026-04-29 18:25:40 -07:00
Patrick Buckley 4c6a62933f fix(coord): auto-refresh console coord_registry on model-definition changes
The console builds ``app.state.coord_registry`` once at lifespan startup
and the coordinator session factory closes over that exact instance.
Until now, the model-definition admin endpoints (create/update/delete)
wrote to the DB but never touched the in-process registry — and the
explicit reload button only fanned out to nodes via HTTP, also leaving
the console's own registry stale.

Symptom: an operator who changed the underlying model name behind a
local-LLM alias (same alias, same endpoint) saw the DB row update
immediately, but coordinator sessions kept calling the prior model
name until the console process was restarted.

Fix: a new helper ``_refresh_console_coord_registry`` rebuilds a fresh
ModelRegistry from DB and applies it to ``app.state.coord_registry``
via the existing thread-safe ``ModelRegistry.reload()`` — in-place
mutation preserves object identity so the factory closure keeps
working, and active coord sessions auto-pick up the swap on their
next ``send()`` via ``ChatSession._refresh_model_from_registry``.

Wired into four endpoints in ``console/server.py``:

- ``admin_create_model_definition`` — after the DB write
- ``admin_update_model_definition`` — after the DB write, gated on
  ``if updates:`` so a no-op PUT skips the rebuild
- ``admin_delete_model_definition`` — after the DB write
- ``admin_model_reload`` — between ``_publish_config_change`` and
  ``_notify_nodes_model_reload`` so the console mirrors what the
  reload broadcasts to nodes

Failure isolation: a load or reload error leaves the existing registry
intact (logged + swallowed). Coord stays usable while the operator
investigates; the explicit reload remains the user-facing recovery path.

No node fan-out on CRUD — the explicit reload button continues to gate
cluster-wide HTTP propagation, preserving today's UX semantics on shared
clusters.

Tests in ``tests/test_admin_model_registry_refresh.py`` cover:

- helper-level: rebuild from DB, identity preservation, no-op when
  registry is None, preservation on load failure / no-enabled-rows /
  reload validation error
- endpoint-level: create / update / delete / explicit-reload all
  refresh the registry; an empty PUT skips the rebuild
2026-04-29 18:25:40 -07:00
Patrick Buckley ce129da7a0 fix(coord): raise wait_for_workstream message cap to 10 KiB
Production fan-outs are frequently hitting the 6 KiB per-child cap by
just 1-2 KiB, forcing the coordinator into a follow-up inspect_workstream
round-trip per truncated child to recover the tail. Bumping the cap to
10 KiB absorbs the common overshoot without changing the truncation
semantics — truncated=True still fires for genuinely oversized messages,
and inspect_workstream remains the unbounded follow-up.

Worst-case context impact: a 32-child fan-out at the cap is now ~320 KiB
(was ~192 KiB), still well within commercial model context windows.
Typical fan-outs of 1-5 children land at 10-50 KiB.

LAST_ERROR_MAX_LEN (1 KiB) is unchanged — it's intentionally smaller
than the wait cap so error truncation happens at write time, and
1 KiB still sits well below 10 KiB.

WAIT_MESSAGE_MAX_BYTES is referenced by name (not literal 6144) in the
truncation test, so no test value needs updating.
2026-04-29 17:32:41 -07:00
Patrick Buckley a697bb900c fix(coord): make coordinator fan out independent work by default
The coordinator system message was descriptive about parallelism rather
than prescriptive — "while multiple children run in parallel" framed
fan-out as incidental, and "a tasks entry, a child to own it" primed
singular delegation. The spawn_batch example (benchmark A, benchmark B,
prototype the winner) showed dependent work under a fan-out framing,
teaching the wrong shape.

In practice the coordinator failed to decompose enumerable requests
("top stories on HN, Lobsters, /r/programming, …") without explicit
"please fan this out" instructions, on both GPT-5.5 and Claude Opus.

base_coordinator.md
- Replace singular "a tasks entry, a child to own it" with plural
  "enumerate the independent units of work, spawn one child per unit,
  run them in parallel by default. Sequential only when one child's
  output feeds the next."
- Tighten the delegation paragraph.

tools_coordinator.md
- Drop the persona repetition that duplicated base_coordinator.md.
- Drop the prescriptive "## Workflow shape" section (the cost note is
  already in wait_for_workstream's tool description; the edit-X
  redirect is already in the persona).
- Drop "in one approval" / "single approval" mentions to avoid
  surfacing approval mechanics to the model.
- Replace the misleading spawn_batch example with truly independent
  items; drop "(up to 10)" which overstated the cap (it's per-call,
  not global, and is documented in the tool schema).
- Add a course-correction example to send_to_workstream — the pattern
  coordinators most often replace with cancel-and-respawn.
- Drop the read action from the tasks examples to keep the lifecycle
  (add → update → remove) coherent.

Coord system message ~16% shorter (4440 → 3722 chars). Both GPT-5.5
and Claude Opus now naturally decompose the news-board prompt without
explicit fan-out instructions. 29 prompt-composition tests pass.
2026-04-29 17:22:25 -07:00
Robert DeAngelis 2cdf87b115 fix(server): accept --skip-permissions CLI flag (#450)
The server's --help epilog and compose.yaml both reference
--skip-permissions, but the argparser never defined it, so any
container started with SKIP_PERMISSIONS=1 exited with
"unrecognized arguments: --skip-permissions".

Wire the flag through to app.state.skip_permissions, OR-ing it
with the existing tools.skip_permissions config-store setting so
the stored value still works on its own.
2026-04-29 14:12:28 -07:00
168 changed files with 22136 additions and 9260 deletions
+11 -1
View File
@@ -156,7 +156,17 @@ jobs:
- run: uv sync --frozen --all-extras
- run: uv pip install pip-audit
- name: Security audit (dependencies)
run: uv export --no-emit-project --frozen | uv run pip-audit --strict --desc -r /dev/stdin
# PYSEC-2025-183 (pyjwt): "weak encryption" — disputed by the
# supplier because the key length is chosen by the calling
# application, not the library. Turnstone generates its JWT
# signing keys via the standard ``secrets`` module at
# operator-controlled strength (see ``turnstone/core/auth.py``),
# so the advisory does not apply. pyjwt 2.12.1 is the current
# latest release; no fix version exists.
run: >-
uv export --no-emit-project --frozen
| uv run pip-audit --strict --desc -r /dev/stdin
--ignore-vuln PYSEC-2025-183
security-ts:
runs-on: ubuntu-latest
+643 -3
View File
@@ -8,13 +8,653 @@ version numbers (`X.Y.Z`, with `X.Y.ZaN` / `bN` / `rcN` for pre-releases).
Three release tracks are maintained:
- **`stable/1.0`** — patch-only (`v1.0.x`)
- **`stable/1.3`** — patch-only (`v1.3.x`)
- **`stable/1.4`** — patch-only (`v1.4.x`)
- **`main`** — experimental (`v1.5.0aN`)
- **`stable/1.5`** — patch-only (`v1.5.x`)
- **`main`** — experimental (next major)
## [Unreleased]
### Added
- **`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`.
### 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`.
## [1.5.17]
Backports a clutch of coordinator-tool clarity fixes plus a watch-delivery
correctness fix from `main` to the `stable/1.5` track, plus a previously-
latent intent-verdicts persistence bug exposed by the new heuristic-verdict
INSERT paths. No schema changes.
### Fixed
- **`intent_verdicts` PK collisions on every llm_fallback delivery** —
async LLM-tier "llm_fallback" verdicts (`turnstone/core/judge.py`
`_deliver_fallbacks` and the in-loop fallback path) deliberately
reuse the heuristic verdict's `verdict_id` so the row gets
"upgraded in place" from `tier="heuristic"``tier="llm_fallback"`
when the LLM judge times out, is cancelled, or returns no content.
The consumer `_persist_intent_verdict` was doing a plain INSERT,
hitting the `intent_verdicts_pkey` constraint on every fallback
delivery; Postgres logged the duplicate-key error, the application
try/except swallowed it at `log.debug`, and the row never actually
got upgraded — the LLM judge's annotation
(`"(LLM judge did not return a verdict)"`) was lost. The collision
rate exploded on this release because the new heuristic-INSERT
paths in the auto-approve early-return branches of `approve_tools`
(introduced below) leave no gap for the fallback to land cleanly
into. Fix: new `upsert_intent_verdict` storage method using
`ON CONFLICT (verdict_id) DO UPDATE` that updates only `tier`,
`reasoning`, `judge_model` — the three fields that genuinely
change between heuristic and llm_fallback. Every other column
(identity, carried-verbatim, and `user_decision`) is excluded;
`user_decision` in particular would otherwise be clobbered back
to `"pending"` when a fallback arrives after the operator has
already resolved the approval. The bulk-INSERT path stays as
plain INSERT — fresh UUIDs in `judge.evaluate` make in-turn dups
impossible; the inverse race (fallback wins before bulk lands) is
reachable but unchanged in observable behavior by this fix,
documented at the bulk site for a future hardening pass.
- **Coordinator LLM re-spawn loops on large fan-outs** — the spawn-tool
return JSON used `ws_id` as its key, which primed the model's recency
bias to feed the spawn result straight back into another
`spawn_workstream(ws_id=...)` call instead of progressing to
`wait_for_workstream(ws_ids=[...])`. On 10+ child fan-outs this cascaded
into self-inflicted re-spawn loops. The LLM-facing tool result now emits
`child_ws_id` (the storage column / HTTP API contract is unchanged); the
field name is already an existing project term so the rename aligns
rather than introduces new vocabulary. Also handles the silent
upstream-omits-ws_id success-shape edge that previously emitted
`{"child_ws_id": null}` to the LLM — now surfaces a tool error so the
model retries rather than chasing a null id.
- **`inspect_workstream` blowing the coordinator context budget** — a
coord doing a fan-out wave against tool-heavy children could land
>100 KB of raw output per inspect call, and the previous safety net
(`_truncate_output`'s head+tail strategy) silently dropped *middle*
messages — exactly the wrong shape for understanding a child's
trajectory (the FIRST sets the brief, the LAST shows the conclusion,
the middle is the connective tissue). Output now goes through a
three-tier degradation ladder mirroring the search tool's
`_format_search_results`: `_tier="full"` (every message verbatim) →
`_tier="compact"` (per-message head/tail-snipped content + snipped
`tool_calls.arguments`, falling through a `(20,30)` / `(10,20)` /
`(5,10)` message-list trim ladder) → `_tier="skeleton"` (counts, role
distribution, last-assistant preview). Budget 32 KiB matches the
search tool's; the chosen tier is annotated on the response so the
model can recall with a tighter `message_limit` if signal was lost.
- **Auto-approved verdicts indistinguishable from pending review** —
`intent_verdict` rows for auto-approved tool calls landed with
`user_decision=""`, which read identically to "still waiting for the
operator" in the audit trail and led to a real misdiagnosis incident.
The column now carries an explicit vocabulary at insert: `pending` /
`approved` / `denied` / `timeout` / `policy` / `blanket` / `skill` /
`always` / `auto_approve_tools`. The auto-approve early-return
branches in `approve_tools` now persist heuristic verdicts stamped
with their reason (previously dropped on the floor), and late LLM-tier
verdicts that arrive for an already-auto-approved call_id are stamped
via a TTL-pruned lookup map — so the audit row carries the
auto-approve reason even when the LLM judge daemon completes after
the synchronous approval cycle finished. `resolve_approval` gains a
`timeout` kwarg writing `"timeout"` (the previous shape collapsed
passive timeouts and active denials into the same column).
- **`list_skills` empty `allowed_tools` misread as "no tool access"** —
the response previously emitted `"allowed_tools": []` for every skill
that hadn't declared an auto-approve allowlist, which a coordinator
model read as "this skill can't use any tools" (real misdiagnosis: a
code-review child appeared to have been spawned with zero tool
access). The field is now omitted entirely when empty — absence
carries the unambiguous meaning "no tool is pre-approved for this
skill", presence (non-empty list) keeps the standard Claude Code
skill-spec shape. The tool description rewrite makes the
auto-approve-allowlist semantics explicit so a future reader doesn't
re-derive the gating misread.
- **Watch terminal-fires silently dropped on backpressure** —
delivery now routes terminal events through the same path as
normal fires instead of being filtered out when the consumer was
saturated.
### Documentation
- **Storage `LIKE_ESCAPE` contract** — clarify that callers passing
`.like(escape=...)` must use the same escape character that the
storage helper assumes; previous wording let a reader pass a
different escape and silently produce no matches.
## [1.5.15]
### Fixed
- **Admin console blank-page on MCP server rows with consented users** — a
Phase 9 (1.5.14) regression in `admin.js` used double-quote string
delimiters on the bulk-revoke button HTML literal, but the literal embeds
a `"` mid-attribute. JS closed the string early, turned `bulk-revoke (`
into bare tokens, and the resulting `SyntaxError` wiped out every global
in `admin.js``showAdmin` and all other admin entry points became
undefined, so the console UI was non-functional whenever the rendered MCP
server list contained at least one row with `consented_users_count > 0`.
Switch the literal to single-quote delimiters to match the surrounding
block.
## [1.5.14]
Backports OAuth-MCP Phase 9 from `main` to the `stable/1.5` track.
### Added
- **OAuth-MCP Phase 9 — admin status, deferred-consent persistence, operator
docs** — completes the per-(user, server) OAuth-MCP build-out. The sync pool
dispatchers now upsert into a new `mcp_pending_consent` table on
`mcp_consent_required` / `mcp_insufficient_scope`, so a non-interactive run
(scheduled / channel) that hits an unconsented server surfaces the deferred
prompt to the user on their next dashboard load via the gear-icon badge —
rows are cleared automatically by the OAuth callback handler on consent
completion, or via new DELETE endpoints for manual dismiss. The MCP Servers
admin row gains a `consented_users_count` pill and a two-step-confirm
bulk-revoke button for `auth_type=oauth_user` servers (upstream RFC 7009
revoke is intentionally not attempted in bulk to avoid N synchronous
round-trips against the provider). Operator-facing docs land at
`docs/mcp-oauth.md` and `docs/operations/mcp-oauth-headless.md`.
Introduces forward-only migrations `054_mcp_pending_consent` and
`055_mcp_user_tokens_server_index`.
## [1.5.13]
This release introduces one forward-only schema migration:
`053_services_notify_trigger` — installs the `services_notify` PostgreSQL
trigger that backs the new LISTEN/NOTIFY dispatcher (no-op on SQLite, where
the dispatcher uses in-process fan-out).
### Added
- **Reactive node discovery via PG LISTEN/NOTIFY** — the console gains a
`NotifyDispatcher` that holds a dedicated session-mode PostgreSQL `LISTEN`
connection (bypasses pgbouncer transaction pooling) and fans wake-ups out to
per-channel handlers on a separate dispatch thread. The cluster collector
subscribes to a new `services` channel and reacts to node register /
deregister within ~500 ms instead of waiting up to 60 s for the next discovery
loop; the 60 s loop is retained as the backstop for crash-shaped loss
(NOTIFY only fires on real writes). The storage layer also gains a uniform
`notify` / `listen` API with an SQLite synthetic-sweep fallback so consumer
code is identical across backends. `TURNSTONE_DB_LISTEN_URL` (or
`[database] listen_url` in `config.toml`) points the dispatcher at a
direct-to-Postgres URL; defaults to the main DB URL when unset.
- **Event-driven `wait_for_workstream`** — coord's block-wait tool no longer
polls storage every 500 ms. A new in-process `ChildEventBus` notifies waiters
whenever a child state change is dispatched to the UI, and the wait loop
blocks on `threading.Event.wait` with a 2 s heartbeat cap (matching the
existing `wait_progress` SSE cadence). A 600 s wait that previously hit
storage ~2400 times now wakes only on real state transitions, with ~4× lower
SSE traffic in the quiescent case.
- **Memory tool audit trail** — the memory tool now emits `memory.save`,
`memory.update`, and `memory.delete` audit events (the admin-console DELETE
route previously emitted only `memory.delete`, so tool-initiated mutations
had no audit footprint). All emissions are best-effort and never break the
tool call itself.
- **`task_agent` per-call personas via `skill=`** — `task_agent` now accepts
an optional `skill=<name>` argument that loads the named skill's content as
the sub-agent's persona in place of the hardcoded identity statement. The
fixed operating-guidance block (one-shot, tool-use over narration,
no follow-up questions) is still layered on top of every persona. High- and
critical-risk skills surface their risk tier in the approval header and
emit a `task_agent.high_risk_skill` warning, matching the existing
session-load gate.
### Fixed
- **Per-role plan / task model overrides could be bypassed by the LLM** — the
back-compat `default` alias auto-synthesised by `load_model_registry`
remained visible to the model even when an operator had configured
`model.task_alias` / `model.plan_alias`, so `task_agent(model="default")`
routed to whichever backend the synthesised alias was attached to at boot
instead of the configured per-role default. The synthesised alias is now
only added when neither the DB nor `[models.*]` populates the registry,
filtered out of the LLM-visible alias list, and explicitly rejected at the
validator chokepoint as defense-in-depth.
- **Mermaid streaming parse errors + progressive `hljs`** — live-streamed
mermaid blocks with bare `(`, `[`, `{` inside unquoted edge or rectangle
node labels were re-entering the shape parser and producing
`Parse error, got 'PS'` messages. The renderer now autoquotes the two
affected label forms (`|content|` and `ID[content]`) before the SVG cache
lookup; shapes whose syntax already nests delimiters (cylinders, subroutines,
trapezoids, etc.) are intentionally left alone. The companion `hljs` change
highlights code blocks progressively as they stream rather than only after
completion.
- **Re-auth from inside the proxy-prefixed UI** — on a proxied node page
(`/node/{id}/...`), an expiring JWT triggered an in-page login modal whose
POST went to `/v1/api/auth/login` and was rewritten to
`/node/{id}/v1/api/auth/login`. Two latent bugs both blocked re-auth: the
console's `AuthMiddleware` didn't recognise the `/node/{id}/` prefix over a
public path, and `proxy_api` would have forwarded the login request to the
upstream node (which mints `JWT_AUD_SERVER` tokens the console then rejects).
Both fixed: proxied public paths stay public, and `proxy_api` now dispatches
every entry in `_PROXY_AUTH_LOCAL_HANDLERS` (login, logout, setup, refresh,
status, whoami, oidc/authorize, oidc/callback) to the console's own auth
handlers. The dispatch table is a single `(method, path) → handler` mapping
so the test parametrize list can't drift from the implementation.
- **Appbar visibility + gear-icon dropdown on the dashboard** — the dashboard
overlay was covering the entire appbar, hiding the proxy-injected node
picker. The overlay now starts at `top: 48px` and the dashboard's role
downgrades from `dialog+aria-modal` to `region` so the appbar above it
remains reachable. The gear icon converts from a direct settings-panel
click into a dropdown with "MCP connections" and "Logout" (the latter with
`.destructive` styling). The settings-menu keydown handler is now attached
synchronously so `Escape` can't fall through the brief window between the
menu opening and its listeners being installed.
- **PostgreSQL test backend on the notify dispatcher suite** — migration 053's
`services_notify` trigger lives only in the alembic chain, but the test
fixture creates tables via `metadata.create_all`. The trigger function +
trigger are now declared in `_schema.py` and attached via
`sa.event.listen(services, "after_create", ...)` DDL events gated on the
PostgreSQL dialect, with the same SQL constants imported by migration 053
so there's a single source of truth.
## [1.5.12]
### Added
- **Enriched backend error messages** — provider name and attempted URL are now
included in session error responses, so operators can triage connectivity
failures without enabling debug logging.
### Fixed
- **`/rewind` always emits a `history` SSE event** — pre-fix, if the session
had no messages remaining after a rewind the history event was skipped,
leaving connected UIs with stale content and blocking edit-and-resend flows.
## [1.5.11]
This release introduces one forward-only schema migration:
`052_model_reasoning_persistence``surface_persisted_reasoning` and
`replay_reasoning_to_model` flag columns on `model_definitions`.
### Added
- **SSE refresh-resume** — clients that reload mid-stream (browser refresh, tab
restore) now receive an `in_progress_snapshot` event carrying the buffered
partial response, so the UI can resume rendering the in-flight turn without
losing content. The snapshot is keyed by a monotonic `_ws_inflight_seq`
counter so a reconnecting client can skip events it already saw.
- **Reasoning persistence** (Phases 14) — model reasoning text can now be
persisted to conversation history and optionally replayed to the model on
subsequent turns. Phase 1 persists reasoning text on the history payload.
Phase 2 wires a build-time shape filter and a per-model
`replay_reasoning_to_model` flag. Phases 3+4 add full OpenAI Responses API
(`include=["reasoning.encrypted_content"]`) and Chat Completions support;
an `ANTHROPIC_VALID_BLOCK_TYPES` shape filter guards the Anthropic path. Two
new per-model capability flags (`surface_persisted_reasoning`,
`replay_reasoning_to_model`) both default `False` on unknown and
local-server models.
- **Console home composer: placeholders + toggle** — the console landing-page
composer now shows context-aware placeholder text and a toggle component for
advanced options; an admin polish pass tightened spacing and focus behaviour
across the form.
### Changed
- **`judge.model` now requires a named alias** — raw provider model IDs on
`judge.model` in config are no longer accepted; the judge must reference an
alias registered in the model registry. The session-provider raw-model
fallback is removed. Existing configs using an unregistered model ID need a
corresponding alias entry.
### Fixed
- **`replay_reasoning_to_model` AND-gated with model capability** — setting the
flag for a model that does not declare reasoning-replay support now silently
no-ops instead of forwarding reasoning blocks and triggering a provider error.
- **Coordinator alias resolution unified across placeholder + factory** — a
placeholder coordinator and the real coordinator factory could previously
resolve to different model aliases, producing a visible mismatch in the model
display. Both paths now share the same resolution logic.
- **Console `cs=None` fallback in `/v1/api/models` placeholder** — an
under-initialised coordinator state no longer 500s when the models endpoint
is hit before the coordinator subsystem is fully bootstrapped.
- **SSE `_ws_inflight_seq` always advances** — sequence numbers were previously
skipped when an emit was past the buffer cap, leaving gaps in the monotonic
counter that broke `state_change` / `in_progress_snapshot` ordering on
reconnect.
- **Reasoning persistence shape + replay fixes** — per-block
`ANTHROPIC_VALID_BLOCK_TYPES` filter applied; `reasoning_text` is now
synthesised alongside non-reasoning `provider_blocks` so both appear
together in the history payload.
## [1.5.10]
This release introduces one forward-only schema migration:
`051_skill_notify_on_complete_array_default` — backfills
`prompt_templates.notify_on_complete` from `'{}'` to `'[]'`.
### Added
- **Skills unlock action** — operators can unlock an installed skill to allow
local customisation. Once unlocked, the skill's resource content, system
prompt additions, and notify configuration are editable through the admin UI.
Skills shipped as part of a bundle remain locked (read-only) until explicitly
unlocked; the unlock is logged to the audit trail. A lock icon in the
top-right of the Skills detail pane doubles as the unlock trigger.
### Fixed
- **`skills.sh` install endpoint** — the install script was targeting an
endpoint removed in an earlier refactor; switched to `/api/download`.
- **Skills `notify_on_complete` default** — the field defaulted to `{}`
(object) instead of `[]` (array), causing notify configurations to be
rejected at schema validation.
- **Skills admin UI modal errors** — `.is-visible` class used consistently
instead of inline `style.display`; stale error text is cleared on submit;
designer-review lock-icon UX applied.
## [1.5.9]
### Fixed
- **`repair=False` on all display-read `load_messages` call sites** —
passing `repair=True` on display paths was silently mutating the stored
message list, causing divergence between what the UI showed and what the
model received on the next turn.
## [1.5.8]
This release introduces two forward-only schema migrations:
`049_mcp_oauth_schema` — OAuth token + consent tables for MCP servers;
`050_conversations_source_and_reminders``_source` and `_reminders` columns
on `conversations`.
### Added
- **MCP OAuth 2.1 + PKCE** — MCP servers that require OAuth can now be
configured with a client ID and secret through the admin UI. The full token
lifecycle (acquire → refresh → rotate) is managed automatically; tokens are
stored encrypted at rest using a key derived from the JWT secret. The consent
flow runs in-browser via a provider redirect. Rolled out in phases:
- Minimum admin form and OAuth schema (`21663d15`).
- Token-at-rest AES-GCM encryption layer (`a4c335d7`).
- Per-(user, server) OAuth 2.1 + PKCE flow (`b0f7029f`).
- Per-(user, server) `ClientSession` pool with OAuth dispatch (`1a1043c4`).
- SDK 401/403 introspection via httpx response hook (`bde09134`).
- Phase 7 — per-user tool catalog scoping: each user sees only the tools
their OAuth token is permitted to call (`cfc8a6c8`).
- Phase 7b — per-user resource + prompt pool dispatch (`b368bdee`).
- Phase 8 — per-user MCP consent UX: users see a consent dialog on first
use of an OAuth-gated server and can revoke consent from their profile;
admins see per-server consent counts in the MCP Servers tab (`61051339`).
- **Metacognition NudgeQueue** — all advisory channels (repeat-tool nudges,
watch reminders, wake triggers) are unified into a pull-model `NudgeQueue`
that delivers at most one nudge per turn, preventing multi-channel pile-ups
that inflate context. Observable changes:
- Watch results carry metadata (watch ID, `valid_until`, trigger type)
through to the system message so the model can reason about recency.
- Coordinator idle-children observer: a coordinator with no in-flight
children for longer than the configured idle threshold receives a nudge.
- Wake trigger (`IdleNudgeWatcher`): sessions waiting on an external event
can be unblocked via `ChatSession.deliver_wake_nudge_from_queue`.
- Watch switchover: watch results are now enqueued on the `NudgeQueue`
rather than the previous `_watch_pending` list, giving them the same
delivery guarantees and priority handling as other advisories.
- **Structured watch-result card** — the UI renders watch results as a styled
card with a system-nudge marker, distinct from the assistant message body.
On history replay, system-nudge turns are visually distinguished from normal
assistant turns.
- **Side-channel persistence** — `_source` and `_reminders` side-channel
fields are persisted to the `conversations` storage table and restored on
session resume, so metacognitive context survives process restarts. A
`REMINDER_TEXT_STORAGE_CAP` byte clamp prevents unbounded growth.
### Fixed
- **Replay consistency** — queued user messages captured mid-loop are now
persisted and replayed in the correct order on a subsequent `events`
subscription. Coordinator history replay fixed: blank assistant cards and
out-of-order tool results on the coordinator tree no longer occur when the
coordinator has mixed queued + delivered messages.
- **Session reminder preservation on fork + resume** — `_source` and
`_reminders` are carried through workstream fork and restored from storage
on resume.
- **NUL-byte sanitization in storage** — PostgreSQL rejects `\x00` in text
columns; `_source` and `_reminders` now strip NUL bytes on write.
- **Console coordinator subsystem bootstrap** — the coordinator subsystem is
now committed atomically on first model add; startup teardown is offloaded
to avoid blocking the event loop.
- **MCP `asyncio.timeout` over `asyncio.wait_for`** — Python 3.11's
`wait_for` wraps the coroutine in a fresh task, breaking anyio's `aclose`
scope exit. Replaced with `async with asyncio.timeout(N)` for safe cleanup.
- **MCP pool-reuse 401 recovery** — a reused `ClientSession` returning 401
now replaces the pool entry with a fresh session; the carrier token is
owned by the pool entry to prevent a race between the 401 handler and a
concurrent request.
- **OIDC hardening** — multiple security and correctness fixes:
SSRF + plaintext credential exfil via discovery document (sec-1, sec-3);
`TURNSTONE_OIDC_REDIRECT_BASE` now required, Host-header fallback removed
(sec-2); atomic user + identity provisioning prevents orphan rows (bug-1);
callback robustness — typed exceptions, shape checks, log sanitization, JS
race (bug-46, sec-4); role-mapping concurrency serialized (bug-2, perf-1);
stranded-user self-heal on role-mapping failure (cumulative bug-1).
## [1.5.7]
### Added
- **Inline node picker** — a compact node-switcher dropdown in the console
header replaces the "← Back to console" banner, so operators can switch
between nodes without a full navigation.
### Fixed
- **Queued user messages injected mid-loop** — messages queued while a
generation was in progress were not being delivered at the correct seam and
could be dropped or reordered when the worker consumed the queue.
- **Search tool output bounded** — pathological inputs (very long lines with
no whitespace) could produce search results exceeding the context budget.
Output is now clamped before reaching the message.
## [1.5.6]
### Added
- **`api_surface` toggle** — model definitions gain an `api_surface` field
(`"chat"` | `"responses"`) that selects which OpenAI-compatible API surface
the provider client uses. Enables Mistral Medium reasoning via the Responses
surface; Chat Completions remains the default for all other models.
- **Healthy model aliases per node** — `GET /v1/api/cluster/nodes` now
includes a `healthy_aliases` list per node, so the coordinator and operators
can see which model aliases are currently reachable without a separate
per-model health probe.
- **Plan/task agent settings in Models → Roles** — the Models admin tab's
Roles sub-tab gains `plan_agent` and `task_agent` rows so operators can
configure per-kind reasoning effort and alias overrides from the UI rather
than editing `config.toml`. Live-refresh dropdowns update in place when
model definitions change.
### Fixed
- **Memory candidate selection** — recall now uses OR-of-terms BM25 with
query-aware candidate-set selection, dramatically improving recall for
queries whose terms span multiple stored entries.
- **Workstream model + config preserved on rehydrate** — reopening a closed
workstream no longer overwrites the model alias and per-workstream config
with session defaults.
- **Console home composer: attachments + user-message pills** — multipart
attachments in the home composer were not forwarded correctly; user-message
pills in the coordinator chat pane were missing.
## [1.5.5]
### Fixed
- **Saved-workstream tool result rendering** — tool results in closed
workstreams were not rendering on history replay. Audit-trail decoration for
tool calls is now applied on the replay path.
## [1.5.4]
### Added
- **Stage 3 SessionManager Children primitive lift** — child workstreams are
first-class citizens in the cluster event bus. `child_ws_state` events are
pushed through the cluster SSE stream so the console tree view updates in
real time without polling. `list_children` and `get_child` primitives on
`SessionManager` provide a consistent cross-node view of the coordinator's
spawn tree.
- **Multi-select delete for Saved Coordinators** — the Saved Coordinators grid
in the console admin panel now supports checkbox multi-select with a
bulk-delete action.
## [1.5.3]
This release introduces one forward-only schema migration:
`048_workstream_reaper_index` — partial composite index on `workstreams` for
the orphan-reaper query.
### Fixed
- **Coordinator orphan reaping scoped by heartbeat** — the session manager's
`close_idle` pass now scopes the DB-orphan reaper by
`services.last_heartbeat` so workstreams belonging to a live node are not
incorrectly reaped. `bulk_close_stale_orphans` and `touch_workstream`
storage primitives added; a partial composite index keeps the reaper scan
cheap.
- **Coordinator pool idle cleanup** — a periodic task on the console now
closes coordinator pool entries whose session has gone idle past the
configurable threshold, preventing pool exhaustion on long-running consoles.
## [1.5.2]
### Added
- **Metacognition themed reminder bubble** — repeat-tool and user-reminder
nudges are rendered as a distinct styled bubble rather than being injected
inline into the assistant message, making it easier to distinguish model
output from metacognitive annotations. The CLI REPL gains matching
`on_user_reminder` / `on_tool_reminder` callbacks.
### Fixed
- **Metacog streak detector** — the N≥3 sequential-same-call streak detector
now fires correctly on the third repetition; a write-success-clear that
reset the counter after a successful tool call (preventing streaks across
mixed-outcome sequences) was removed.
- **Metacog reminders isolated to side-channel** — reminder text no longer
appears in the user content turn; it flows through a dedicated side-channel
the session injects into the system context, preventing the model from
attributing it to the user.
## [1.5.1]
### Added
- **`pending_approval_detail` on child `ws_state` SSE events** — coordinators
now receive the child's pending approval detail in `child_ws_state` events,
enabling the coordinator to surface approval prompts without a separate poll.
### Fixed
- **Coordinator registry auto-refresh** — the console coordinator registry now
refreshes when model definitions change, so a newly added alias is visible
to coordinators without restarting.
- **Coordinator fan-out default** — coordinators now fan out to independent
child workstreams by default instead of serialising them, matching the
documented contract for parallel-work patterns.
- **`wait_for_workstream` message cap raised to 10 KiB** — large plan
summaries and tool results from child workstreams were silently truncated at
the previous 4 KiB cap.
- **Coordinator SSE isolated on dedicated thread pool** — coordinator SSE
polling now runs on a dedicated 200-thread executor, matching interactive's
`sse_executor`, so coordinator long-poll blocking no longer contends with
storage and routing workers on the default pool.
## [1.5.0]
User-visible additions: a unified workstream HTTP surface (interactive and
coordinator under one URL family), inline child approvals, coordinator
composer parity, progressive rendering, OIDC authentication, MCP OAuth
foundations, and a redesigned UI built on the Design System v1 token layer.
This release removes the pre-1.5 body-keyed and query-keyed URL family.
See **Removed (BREAKING)** below before upgrading from a 1.x stable line.
This release introduces the following forward-only schema migrations that the
server applies automatically on first startup. All are additive; no data loss.
- `039_workstream_kind``kind` + `parent_ws_id` columns on `workstreams`.
- `040_coord_cluster_admin_perms` — grants `admin.coordinator` +
`admin.cluster.inspect` to the builtin-admin role.
- `041_workstream_index_tuning` — refined indexes for the workstream query mix
introduced by 039.
- `042_coord_trust_send_perm` — adds `coordinator.trust.send` permission to
builtin-admin.
- `043_skill_description_required` — backfills empty `description` rows in
`prompt_templates`.
- `044_skill_kind` — adds `kind` classifier column to `prompt_templates`
(`interactive` / `coordinator` / `any`).
- `045_skill_risk_level_rename` — renames `prompt_templates.scan_status`
`risk_level`.
- `046_drop_hash_ring_tables` — drops the hash-ring bucket tables superseded
by rendezvous routing in 1.4.
- `047_drop_coord_spawn_quota_settings` — removes the spawn-quota settings
rows removed from the coordinator in 1.5.0a4.
### Added
- **Inline child approvals** — pending tool approvals on coordinator child
workstreams surface directly in the coordinator tree view. A risk pill shows
the judge verdict (or "pending" while the judge evaluates); Approve/Deny
buttons appear inline so operators do not need to navigate to the child's
workstream. `pending_approval_detail` is exposed on
`GET /v1/api/dashboard` and passed through the cluster live-bulk SSE payload
so all connected clients render approval prompts simultaneously. LLM judge
verdicts are cached client-side and replayed on SSE reconnect.
- **Coordinator composer parity** — the coordinator composer now supports
Stop, Send-to-queue, and Attach (file upload), matching the interactive
workstream composer feature set.
- **Per-call model and judge override on coordinator composer** — operators
can override the model alias and judge model for a single coordinator send
from the composer, without changing the node-wide or role-wide defaults. Bad
aliases return a corrective error listing available choices.
- **Coordinator status bar + richer history replay** — each coordinator
workstream gains a per-coordinator status bar showing active children, token
spend, and generation state. History replay in the coordinator panel is
extended to include tool results and thinking blocks.
- **Coordinator child error surfacing + memory tool** — child workstream
errors are surfaced as distinct error rows in the coordinator tree view
rather than disappearing silently. The coordinator gains access to a
`memory` tool (same interface as interactive) for retrieving stored facts.
- **Coordinator inline tool-batch construct** — the coordinator tool approval
UI replaces the separate approval dock with an inline batch construct that
groups all pending tool calls for a given turn into a single review card.
- **Node capability auto-detection** — nodes report kernel-level capabilities
(available memory, CPU count, accelerator presence) via
`/v1/api/node/capabilities` at startup, enabling the console to filter model
aliases offered to coordinators routing to that node.
- **Skills: paste `SKILL.md` to auto-fill the Create Skill modal** — pasting
a `SKILL.md` file's content into the modal auto-populates the name,
description, and configuration fields.
- **Progressive mermaid rendering** — Mermaid diagrams begin rendering as
soon as a complete diagram block is detected in the stream rather than
waiting for the full response; the diagram re-renders in place as the model
extends it.
- **LaTeX and MathML delimiter support** — `\(…\)` inline and `\[…\]` block
math delimiters are now recognised alongside the existing `$$` fences.
### Removed (BREAKING — 1.5.0)
- **Legacy body-keyed and query-keyed URL family for the workstream
+1 -1
View File
@@ -8,7 +8,7 @@ FROM python:3.14-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.14 /uv /usr/local/bin/uv
# Remove the slim image's man page exclusion so man-db has actual content
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
+1 -1
View File
@@ -91,7 +91,7 @@ turnstone/
discord/ Discord adapter (bot, cog, views, streaming, config)
slack/ Slack adapter (Socket Mode bot, DM routing, approval buttons)
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
katex-0.16.45/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
katex-0.16.47/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
ui/
colors.py ANSI color constants with NO_COLOR support
markdown.py Streaming terminal markdown renderer (line-buffered)
+9 -2
View File
@@ -110,11 +110,18 @@ owns it; the node is just currently unreachable.
### Example — `spawn_batch`
This is the coordinator-tool result shape (the JSON the LLM receives),
not an HTTP API response — the table above keys it under "model tool"
to distinguish it from the `/v1/api/...` endpoints in the same table.
The underlying HTTP spawn endpoint still returns `ws_id`; the tool
result re-keys it to `child_ws_id` to defuse a coordinator-LLM recency
bias (see `docs/coordinator-skills.md`).
```json
{
"results": {
"0": {"ws_id": "d4e5f6...", "name": "csrf-audit", "node_id": "gpu-3"},
"2": {"ws_id": "f1a2b3...", "name": "xss-audit", "node_id": "gpu-1"}
"0": {"child_ws_id": "d4e5f6...", "name": "csrf-audit", "node_id": "gpu-3"},
"2": {"child_ws_id": "f1a2b3...", "name": "xss-audit", "node_id": "gpu-1"}
},
"denied": [
{"idx": 1, "reason": "skill not found: nonexistent-skill"}
+12 -5
View File
@@ -169,14 +169,21 @@ validates ws_id against `parent_ws_id=coord_ws_id` AND
the wait into reporting "complete".
Pattern: capture each spawn result in the next tool call's input.
The JSON tool-result carries `{"ws_id": "...", "name": "...",
The JSON tool-result carries `{"child_ws_id": "...", "name": "...",
"node_id": "...", "routing_strategy": "..."}`; the model should
extract the ws_id and pass it to `inspect_workstream` /
`wait_for_workstream` / `send_to_workstream` / `close_workstream`
verbatim.
extract the `child_ws_id` and pass it as `ws_id` (or in the `ws_ids`
list) to `inspect_workstream` / `wait_for_workstream` /
`send_to_workstream` / `close_workstream` verbatim. The asymmetry
— spawn returns `child_ws_id` but the other tools accept `ws_id` /
`ws_ids` — is intentional: it defuses a coordinator-LLM recency
bias where seeing `ws_id` in a spawn return primed re-spawn loops
instead of progression to the wait phase.
A UI that wants human-readable identifiers should render the `name`
field and keep the ws_id as the click-through key.
field and keep the workstream id as the click-through key — note
that the id *value* is the same regardless of whether it arrived
under the `child_ws_id` key (spawn return) or the `ws_id` key
(every other tool's input/output); only the field name differs.
---
+1
View File
@@ -84,6 +84,7 @@ Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|----------|---------|-------------|
| `TURNSTONE_DB_BACKEND` | `sqlite` | Storage backend: `sqlite` or `postgresql` |
| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql+psycopg://user:pass@postgres:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` |
| `TURNSTONE_DB_LISTEN_URL` | (falls back to `TURNSTONE_DB_URL`) | Direct-to-PostgreSQL URL for the console's dedicated `LISTEN` connection. Set this when `TURNSTONE_DB_URL` points at PgBouncer in transaction pooling mode — LISTEN is session state and the transaction-pooled connection can't hold it. See [pgbouncer.md](pgbouncer.md). |
| `TURNSTONE_DB_POOL_SIZE` | `2` | PostgreSQL connection pool size per process (default: 2 base + 3 overflow = 5 max) |
| `POSTGRES_USER` | `turnstone` | PostgreSQL container username (used in default `TURNSTONE_DB_URL` for cluster/channel) |
| `POSTGRES_PASSWORD` | — | PostgreSQL container password (required for production and cluster profiles) |
+117
View File
@@ -0,0 +1,117 @@
# MCP OAuth — per-user authorization for MCP servers
Turnstone supports **per-(user, MCP server) OAuth 2.1 + PKCE** delegation so each Turnstone user authorizes a remote MCP server with their own identity, rather than sharing a single bearer token across the deployment. This is the right shape for MCP servers that expose user-specific data (a personal CRM, an email inbox, a calendar) and for MCP servers that want per-user audit attribution.
Per-user OAuth is opt-in per `mcp_servers` row. Local-auth Turnstone installs with no `oauth_user` rows exercise zero new code paths — the entire feature is dark by default.
> **Note**: This is a separate authorization layer from Turnstone's own user authentication. A user who logs into Turnstone with a local username + password can still authorize a per-server OAuth MCP server. OIDC SSO and per-server OAuth are orthogonal.
---
## When to use which `auth_type`
The MCP server admin form exposes three authorization modes ("Multitenant Authorization"):
| `auth_type` | What it means | When to use |
|---|---|---|
| `none` | No headers attached. Open MCP server (or one gated by network policy only). | Internal MCP servers on a trusted network. |
| `static` | One static bearer token, configured per server, sent on every request from every user. | Service-to-service MCP servers where per-user attribution doesn't matter, or single-tenant deployments. |
| `oauth_user` *(recommended for user-data servers)* | Each user authorizes separately via OAuth 2.1 + PKCE; Turnstone stores per-user tokens encrypted at rest. | MCP servers that expose user-specific data or that want per-user audit attribution. |
Switching `auth_type` away from `oauth_user` orphans existing per-user tokens. Use the admin **bulk-revoke** affordance on the server row (Phase 9) to clear them, or let them expire naturally — they're inert without the matching `auth_type` value.
---
## Prerequisites for `auth_type=oauth_user`
1. **Encryption key**. Tokens are stored encrypted with Fernet. Set `[security] mcp_token_encryption_key` in `config.toml` (Turnstone won't start with an `oauth_user` row configured but no key installed). Rotate via `MultiFernet` — add the new key first, then later remove the old one once all rows have been re-encrypted.
2. **MCP server publishes RFC 9728 PRM and RFC 8414 AS metadata** *or* you configure the AS URL override on the server row. PKCE S256 is mandatory; Turnstone refuses to connect to authorization servers that don't advertise `code_challenge_methods_supported: ["S256"]`.
3. **OAuth client registration**. Two paths:
- **Pre-registered** (most common): you create an OAuth client at the authorization server (manually, via admin console, or via Terraform), then paste the `client_id` / `client_secret` into the Turnstone admin form.
- **Dynamic client registration** (RFC 7591): if the AS supports it and you select that mode in the admin form, Turnstone registers a client at first use and persists the `client_id` automatically.
4. **Redirect URI** registered at the authorization server: `https://your-turnstone-host/v1/api/mcp/oauth/callback`.
---
## Configuration
### Per-server fields (admin UI)
| Field | Required | Description |
|---|---|---|
| Server URL | Yes | The MCP server's `streamable-http` base URL. |
| Multitenant Authorization | Yes | `none` / `static` / `oauth_user` (recommended). |
| Authorization Server URL | No | Override for RFC 9728 PRM discovery. Set when your AS endpoint differs from the MCP server URL (e.g., corporate AS protecting a third-party MCP). When unset, Turnstone falls back to PRM discovery against the MCP server itself. |
| Client Registration | Yes (oauth_user) | `preregistered` or `dynamic`. |
| Client ID | Yes (preregistered) | OAuth 2.0 client ID. Stored unencrypted. |
| Client Secret | Optional (write-only) | OAuth 2.0 client secret (confidential client). Encrypted at rest. Written but never re-read by the API; field stays masked. |
| Scopes | No | Space-separated default scope set requested at the authorize endpoint. Per-tool step-up may union additional scopes from a server's `insufficient_scope` response. |
| Audience | No | RFC 8707 `resource=` parameter sent on every authorize and token request. Defaults to the MCP server URL when unset. Validate against the `aud` claim in returned JWT tokens. |
### Encryption key
```toml
[security]
mcp_token_encryption_key = "base64-fernet-key"
# For rotation, list the keys in priority order — first is used for new
# writes, all are tried for reads.
# mcp_token_encryption_keys = ["new-key", "old-key"]
```
Keep this in `config.toml` rather than environment variables. An in-process LLM with shell-tool access can read the server's environment via `env` / `os.environ` and exfiltrate any secret stored there; secrets in `config.toml` are only loaded into the server at startup and never re-read on a tool-driven path, so a prompt-injection attack against the agent cannot reach them.
---
## Lifecycle
1. **First tool call** for a user against an `oauth_user` MCP server: pool dispatch finds no stored token, returns `mcp_consent_required` to the agent. Dashboard renders an inline "Connect" action card.
2. **User clicks Connect**: opens `/v1/api/mcp/oauth/start?server=<name>` in a popup. Browser redirects through the AS authorize endpoint, user grants consent, AS redirects back to `/v1/api/mcp/oauth/callback`. Turnstone exchanges code → tokens via PKCE, validates audience, encrypts, persists in `mcp_user_tokens`, redirects user back to the originating URL.
3. **Subsequent tool calls** by the same user against the same server reuse the persisted token via the per-(user, server) session pool. Tokens auto-refresh via the refresh-token grant when expired; failed refresh emits `mcp_consent_required` to drive re-consent.
4. **Step-up scope**: when a tool call hits `403` with `WWW-Authenticate: error="insufficient_scope"`, Turnstone emits `mcp_insufficient_scope` with the parsed scope set; the dashboard offers a "Connect with additional scopes" affordance that opens `/v1/api/mcp/oauth/start?server=<name>&scopes=<extra>` so the union of original + new scopes flows into the AS authorize request.
5. **User revoke** (settings modal): `DELETE /v1/api/mcp/oauth/connections/{server_name}` runs the authoritative local delete + best-effort RFC 7009 upstream revoke (fire-and-forget, capped at 256 concurrent in-flight tasks).
6. **Admin bulk-revoke** (Phase 9): `POST /v1/api/admin/mcp-servers/{name}/bulk-revoke` drops every user's token for the server. Upstream RFC 7009 revoke is intentionally **not** attempted in bulk (avoids N upstream HTTP calls per admin click); tokens at the AS expire naturally. Use the per-user revoke endpoint if you need guaranteed upstream invalidation.
---
## Admin status indicators
The MCP Servers admin tab shows per-server status pills (Phase 9):
- **Consented users count** — distinct users with a non-expired token for this server. Surfaced as a `bulk-revoke (N)` button when ≥1; clicking it opens a confirmation dialog. Hidden when 0.
- **Last refresh** — timestamp + outcome (`ok` / `error:ClassName`) of the most recent manual or auto-reconnect refresh. Per node. Absent until at least one refresh has occurred (renders as "never" in the admin UI).
Additional indicators (circuit-breaker state, encryption-key mismatch) are exposed via `get_server_status` on the API but do not yet have a dedicated admin pill — operators see them today via the per-server status text + error tooltip and in audit logs. A future phase may surface these as discrete pills.
---
## Auth-type transitions
| From | To | What happens |
|---|---|---|
| `none` / `static``oauth_user` | — | New code path activates for this server. Existing static headers (if any) are no longer sent. Users must authorize on first use. |
| `oauth_user``none` / `static` | — | Existing `mcp_user_tokens` rows are **orphaned** — inert without a matching `auth_type`. Use admin bulk-revoke to drop them, or let them expire. Switching back to `oauth_user` later re-activates the orphaned rows if they haven't been deleted. |
| OAuth `client_id` or `client_secret` rotated | — | Existing tokens may stop refreshing if the AS treats them as bound to the previous client. Bulk-revoke after rotation. |
The orphan-by-default behavior is chosen so switching back to `oauth_user` is non-destructive. Bulk-revoke is the explicit cleanup path.
---
## Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
| `mcp_consent_required` even after consenting | Token persistence failed, or refresh-token rejected by AS | Check audit log for `mcp_server.oauth.persist_failed` or `mcp_server.oauth.token_revoked`. Re-consent via settings modal. |
| `mcp_token_undecryptable_key_unknown` | Encryption key rotated without keeping the previous key in the keyring | Add the previous key back to `mcp_token_encryption_keys` until all rows have been re-encrypted, then drop. |
| `mcp_oauth_url_insecure` | MCP server URL is `http://` (not `https://`) on a non-loopback host | Use `https://`. Per-user bearers must not transit cleartext. |
| Tools fail in scheduled / Discord / Slack runs | OAuth-MCP requires browser-based consent | Users must pre-consent via the web UI. Phase 9 dashboard badge surfaces deferred consents from these runs on next login. |
| Circuit breaker open repeatedly | Transport-level errors on the MCP server (DNS, TLS, 5xx) | Check the per-server error pill; auth errors do not trip the breaker. |
See also: `docs/operations/mcp-oauth-headless.md` for the cron / channel-driven run caveat.
+29
View File
@@ -0,0 +1,29 @@
# MCP OAuth in headless / scheduled / channel-driven runs
**Constraint**: OAuth-MCP servers (`auth_type=oauth_user`) require browser-based user consent. Users must pre-consent via the web UI before any run that cannot drive a browser redirect.
**Affected surfaces**:
- Scheduled workstreams (`turnstone-console` task scheduler).
- Discord adapter runs.
- Slack adapter runs.
- Any future channel adapter without an interactive browser session.
**What happens when consent is missing**:
A tool call against an `oauth_user` server returns a structured `mcp_consent_required` error to the agent. The agent surfaces the deferred work in its output. Turnstone persists a record to `mcp_pending_consent` so the dashboard badge surfaces the deferred consent need to the user on next login.
**Recovery**:
The user opens the dashboard, sees the gear-icon badge counting pending consents, opens the settings modal, clicks Connect for each affected server, and completes the OAuth dance. The pending-consent record is cleared by the OAuth callback handler on success. Subsequent scheduled / channel runs use the freshly-stored token.
**Pre-consent recipe**:
Before scheduling a workstream that depends on an `oauth_user` MCP server, the user should:
1. Open the dashboard.
2. Open the settings modal (gear icon).
3. Click Connect on each MCP server the schedule will use.
4. Confirm consent in the popup.
This stores tokens that the scheduled run will reuse. Refresh-token rotation is handled transparently on the run side; only the first consent requires browser interaction.
+24
View File
@@ -199,4 +199,28 @@ does not support prepared statements. Turnstone's SQLAlchemy layer does
not use server-side prepared statements by default, so this is not an
issue.
**LISTEN / NOTIFY not supported in transaction mode** — PgBouncer's
transaction pooling assigns a real server connection only for the
duration of each transaction, then returns it to the pool. PostgreSQL
`LISTEN` is session state — a transaction-pooled client can't hold the
multi-statement session a long-lived `LISTEN` needs. The console's
`NotifyDispatcher` (reactive node discovery via the `services` channel)
therefore opens a **dedicated, direct-to-Postgres** connection that
bypasses PgBouncer.
Configure via `config.toml` `[database] listen_url` (preferred —
co-located with the main `url`) or the `TURNSTONE_DB_LISTEN_URL` env var
(config.toml wins when both are set). Defaults to the main DB URL when
unset.
| Setting | Behaviour |
|---|---|
| unset | Listener uses `TURNSTONE_DB_URL` as-is. Fine when PgBouncer is in **session** mode, or when there's no pooler in front of Postgres. With transaction-mode PgBouncer the listener's `LISTEN` will fail and the dispatcher retries with exponential backoff (1 s → 30 s cap) without ever succeeding. Reactive NOTIFY-driven node discovery is silently lost; the cluster collector's 60 s `_discovery_loop` is the only remaining backstop. |
| set to direct-to-PG URL (e.g. `postgresql://…/turnstone`) | Listener bypasses PgBouncer for its one dedicated connection. Reactive discovery latency drops from up-to-60 s to ~500 ms. The rest of the storage layer continues to go through PgBouncer in transaction mode. |
Set this whenever PgBouncer is in transaction mode (the recommended
setting per this doc). The override only adds one long-lived PG
connection per console process — sized into the cluster's
`max_connections` budget alongside the pool.
See also: [Docker deployment](docker.md) · [Security](security.md)
+4 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.5.12"
version = "1.6.0a2"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
@@ -22,7 +22,7 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = [
"openai>=2.24",
"openai>=2.37",
"httpx>=0.28",
"mcp>=1.27",
"starlette>=0.45",
@@ -82,9 +82,9 @@ include = [
"turnstone/console/static/coordinator/*.js",
"turnstone/shared_static/*.css",
"turnstone/shared_static/*.js",
"turnstone/shared_static/katex-0.16.45/**/*",
"turnstone/shared_static/katex-0.16.47/**/*",
"turnstone/shared_static/hljs-11.11.1/**/*",
"turnstone/shared_static/mermaid-11.14.0/**/*",
"turnstone/shared_static/mermaid-11.15.0/**/*",
"turnstone/shared_static/hls-1.6.16/**/*",
"turnstone/sdk/py.typed",
"turnstone/deploy/*.yaml",
+6 -3
View File
@@ -50,11 +50,14 @@ update_refs() {
local old_pattern="$1" # e.g. katex-0.16.38
local new_pattern="$2" # e.g. katex-0.16.39
# Find all files with version references (excludes vendored JS and worktrees)
# Find all files with version references. Excludes the old versioned vendor
# directory itself (about to be rm -rf'd anyway) so we don't bother rewriting
# self-references inside it — but does NOT exclude all of shared_static/,
# because shared_static/renderer.js loads the vendored libs and needs the bump.
local files
files=$(grep -rl --include='*.toml' --include='*.html' --include='*.js' --include='*.md' \
files=$(grep -rl --include='*.toml' --include='*.html' --include='*.js' --include='*.md' --include='*.py' \
-F "$old_pattern" . \
--exclude-dir='.claude' --exclude-dir='node_modules' --exclude-dir='shared_static' \
--exclude-dir='.claude' --exclude-dir='node_modules' --exclude-dir="$old_pattern" \
2>/dev/null || true)
for f in $files; do
sed -i "s|${old_pattern}|${new_pattern}|g" "$f"
+127 -127
View File
@@ -74,9 +74,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -84,9 +84,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@@ -101,9 +101,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@@ -118,9 +118,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@@ -135,9 +135,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@@ -152,9 +152,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@@ -169,9 +169,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@@ -189,9 +189,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@@ -209,9 +209,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@@ -229,9 +229,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@@ -249,9 +249,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@@ -269,9 +269,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@@ -289,9 +289,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@@ -306,9 +306,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@@ -325,9 +325,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@@ -342,9 +342,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@@ -359,9 +359,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -402,23 +402,23 @@
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -427,13 +427,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"@vitest/spy": "4.1.6",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -454,9 +454,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -467,13 +467,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.6",
"pathe": "^2.0.3"
},
"funding": {
@@ -481,14 +481,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/pretty-format": "4.1.6",
"@vitest/utils": "4.1.6",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -497,9 +497,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -507,13 +507,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/pretty-format": "4.1.6",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -959,9 +959,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -988,14 +988,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
"@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -1004,21 +1004,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
"@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/siginfo": {
@@ -1119,16 +1119,16 @@
}
},
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"postcss": "^8.5.14",
"rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
@@ -1145,7 +1145,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -1197,19 +1197,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/expect": "4.1.6",
"@vitest/mocker": "4.1.6",
"@vitest/pretty-format": "4.1.6",
"@vitest/runner": "4.1.6",
"@vitest/snapshot": "4.1.6",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -1237,12 +1237,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"@vitest/browser-playwright": "4.1.6",
"@vitest/browser-preview": "4.1.6",
"@vitest/browser-webdriverio": "4.1.6",
"@vitest/coverage-istanbul": "4.1.6",
"@vitest/coverage-v8": "4.1.6",
"@vitest/ui": "4.1.6",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+220
View File
@@ -0,0 +1,220 @@
"""Tests for turnstone-admin DB configuration precedence.
Locks in the alignment with turnstone-server:
CLI / config.toml [database] > TURNSTONE_DB_* env > hardcoded default
The motivation is to keep DB secrets in config.toml (see
feedback_secrets_not_in_env) rather than forcing operators to export
TURNSTONE_DB_URL before every admin invocation.
"""
from __future__ import annotations
import argparse
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
import turnstone.core.config as config_mod
from turnstone.admin import _get_storage
def _reset_cache() -> None:
config_mod._cache = None
config_mod._config_path = None
def _build_args(config_path: str | None) -> argparse.Namespace:
"""Build an args namespace the way admin.main() does.
Skips ``add_config_arg`` (which reads ``sys.argv``) — the test
constructs the args programmatically instead.
"""
config_mod.set_config_path(config_path or "/nonexistent/turnstone-admin-test.toml")
parser = argparse.ArgumentParser()
config_mod.apply_config(parser, ["database"])
sub = parser.add_subparsers(dest="command")
sub.add_parser("list-users")
return parser.parse_args(["list-users"])
@pytest.fixture(autouse=True)
def _clear_db_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Clean slate: no TURNSTONE_DB_* env vars unless a test sets them."""
for var in (
"TURNSTONE_DB_BACKEND",
"TURNSTONE_DB_URL",
"TURNSTONE_DB_PATH",
"TURNSTONE_DB_POOL_SIZE",
"TURNSTONE_DB_SSLMODE",
"TURNSTONE_DB_SSLROOTCERT",
"TURNSTONE_DB_SSLCERT",
"TURNSTONE_DB_SSLKEY",
"TURNSTONE_CONFIG",
):
monkeypatch.delenv(var, raising=False)
_reset_cache()
yield
_reset_cache()
def test_defaults_to_sqlite_when_neither_config_nor_env_set() -> None:
args = _build_args(None)
with patch("turnstone.core.storage.init_storage") as init:
_get_storage(args)
assert init.call_args.args == ("sqlite",)
assert init.call_args.kwargs["url"] == ""
assert init.call_args.kwargs["path"] == ""
assert init.call_args.kwargs["pool_size"] == 2
def test_config_toml_database_section_drives_init_storage(tmp_path: Path) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"[database]\n"
'backend = "postgresql"\n'
'url = "postgresql+psycopg://fromconfig:x@host/db"\n'
"pool_size = 5\n"
'sslmode = "verify-full"\n'
'sslrootcert = "/etc/ssl/ca.pem"\n'
'sslcert = "/etc/ssl/client.pem"\n'
'sslkey = "/etc/ssl/client.key"\n'
)
args = _build_args(str(cfg))
with patch("turnstone.core.storage.init_storage") as init:
_get_storage(args)
assert init.call_args.args == ("postgresql",)
kw = init.call_args.kwargs
assert kw["url"] == "postgresql+psycopg://fromconfig:x@host/db"
assert kw["pool_size"] == 5
assert kw["sslmode"] == "verify-full"
assert kw["sslrootcert"] == "/etc/ssl/ca.pem"
assert kw["sslcert"] == "/etc/ssl/client.pem"
assert kw["sslkey"] == "/etc/ssl/client.key"
def test_env_used_as_fallback_when_config_absent(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TURNSTONE_DB_BACKEND", "postgresql")
monkeypatch.setenv("TURNSTONE_DB_URL", "postgresql+psycopg://fromenv:x@host/db")
monkeypatch.setenv("TURNSTONE_DB_POOL_SIZE", "7")
monkeypatch.setenv("TURNSTONE_DB_SSLMODE", "require")
args = _build_args(None)
with patch("turnstone.core.storage.init_storage") as init:
_get_storage(args)
assert init.call_args.args == ("postgresql",)
kw = init.call_args.kwargs
assert kw["url"] == "postgresql+psycopg://fromenv:x@host/db"
assert kw["pool_size"] == 7
assert kw["sslmode"] == "require"
def test_config_toml_wins_over_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""config.toml beats env — operators should put secrets in TOML."""
monkeypatch.setenv("TURNSTONE_DB_BACKEND", "sqlite")
monkeypatch.setenv("TURNSTONE_DB_URL", "postgresql+psycopg://fromenv:x@host/db")
monkeypatch.setenv("TURNSTONE_DB_SSLMODE", "require")
cfg = tmp_path / "config.toml"
cfg.write_text(
"[database]\n"
'backend = "postgresql"\n'
'url = "postgresql+psycopg://fromconfig:x@host/db"\n'
'sslmode = "verify-full"\n'
)
args = _build_args(str(cfg))
with patch("turnstone.core.storage.init_storage") as init:
_get_storage(args)
assert init.call_args.args == ("postgresql",)
kw = init.call_args.kwargs
assert kw["url"] == "postgresql+psycopg://fromconfig:x@host/db"
assert kw["sslmode"] == "verify-full"
def test_partial_config_falls_through_to_env_per_key(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A key missing from [database] should fall back to its env var."""
monkeypatch.setenv("TURNSTONE_DB_SSLMODE", "require")
monkeypatch.setenv("TURNSTONE_DB_POOL_SIZE", "9")
cfg = tmp_path / "config.toml"
cfg.write_text(
'[database]\nbackend = "postgresql"\nurl = "postgresql+psycopg://fromconfig:x@host/db"\n'
)
args = _build_args(str(cfg))
with patch("turnstone.core.storage.init_storage") as init:
_get_storage(args)
kw = init.call_args.kwargs
assert kw["url"] == "postgresql+psycopg://fromconfig:x@host/db"
assert kw["sslmode"] == "require"
assert kw["pool_size"] == 9
def test_empty_string_in_config_beats_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""`url = ""` in config.toml beats an env var.
Locks in the `is not None` guard — a falsy-but-present TOML value
should NOT silently fall through to the env fallback.
"""
monkeypatch.setenv("TURNSTONE_DB_URL", "postgresql+psycopg://fromenv:x@host/db")
cfg = tmp_path / "config.toml"
cfg.write_text('[database]\nbackend = "sqlite"\nurl = ""\n')
args = _build_args(str(cfg))
with patch("turnstone.core.storage.init_storage") as init:
_get_storage(args)
assert init.call_args.kwargs["url"] == ""
def test_main_threads_config_toml_through_real_argv(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""End-to-end: ``turnstone-admin --config <toml> list-users`` honors TOML.
Covers the ``add_config_arg`` -> ``apply_config`` -> ``_get_storage``
chain that the programmatic ``_build_args`` helper skips.
"""
cfg = tmp_path / "config.toml"
cfg.write_text(
'[database]\nbackend = "postgresql"\nurl = "postgresql+psycopg://fromcli:x@host/db"\n'
)
monkeypatch.setattr("sys.argv", ["turnstone-admin", "--config", str(cfg), "list-users"])
fake_storage = patch("turnstone.core.storage.init_storage").start()
fake_storage.return_value.list_users.return_value = []
try:
from turnstone.admin import main
main()
finally:
patch.stopall()
assert fake_storage.call_args.args == ("postgresql",)
assert fake_storage.call_args.kwargs["url"] == "postgresql+psycopg://fromcli:x@host/db"
def test_get_storage_initializes_real_sqlite_backend(tmp_path: Path) -> None:
"""Drives the real ``init_storage`` boundary on a fresh sqlite file.
Mock-only tests would miss a kwarg-name typo (sslmode -> ssl_mode).
This test trips on any such drift because Alembic + the backend
actually run.
"""
from turnstone.core.storage import reset_storage
db_file = tmp_path / "admin.db"
cfg = tmp_path / "config.toml"
cfg.write_text(f'[database]\nbackend = "sqlite"\npath = "{db_file}"\n')
args = _build_args(str(cfg))
reset_storage()
try:
storage = _get_storage(args)
assert storage.list_users() == []
finally:
reset_storage()
+769 -19
View File
@@ -12,9 +12,27 @@ from __future__ import annotations
import re
from pathlib import Path
import pytest
_APP_JS = Path(__file__).resolve().parent.parent / "turnstone/ui/static/app.js"
def _pane_method_offset(body: str, name: str) -> int:
"""Return the start offset of class method ``name`` in ``body``.
Indent-agnostic — matches the method header at any leading-whitespace
depth (2 spaces for the current class, 4 if the class is ever
wrapped in an IIFE or module, etc.) so slice tests survive deferred
modernization without silent ``ValueError`` failures. Asserts on
miss so a refactor that renames the method fails loudly at the
pinning slice instead of further downstream.
"""
pattern = re.compile(r"^\s{2,}" + re.escape(name) + r"\(", re.MULTILINE)
m = pattern.search(body)
assert m is not None, f"class method {name!r} not found in app.js"
return m.start()
def test_switch_tab_bootstraps_pane_when_none_exists() -> None:
"""``switchTab`` must create a pane when none exists. A fresh-
loaded interactive UI with no workstreams shows the dashboard
@@ -115,8 +133,8 @@ def test_replay_history_renders_content_before_tool_block() -> None:
The test pins the order via the offsets of the ``msg.content`` and
``msg.tool_calls`` branch headers inside the function body."""
body = _APP_JS.read_text(encoding="utf-8")
start = body.index("Pane.prototype.replayHistory = function")
end = body.index("Pane.prototype._attachRetryToLastAssistant", start)
start = _pane_method_offset(body, "replayHistory")
end = _pane_method_offset(body, "_attachRetryToLastAssistant")
fn = body[start:end]
# Locate the assistant branch and bound the search to its body —
# the function also handles user / tool roles which would otherwise
@@ -152,8 +170,8 @@ def test_replay_history_renders_persisted_verdict_badge() -> None:
call. This test pins the call site so a refactor that drops the
decoration regresses the audit surface."""
body = _APP_JS.read_text(encoding="utf-8")
start = body.index("Pane.prototype.replayHistory = function")
end = body.index("Pane.prototype._attachRetryToLastAssistant", start)
start = _pane_method_offset(body, "replayHistory")
end = _pane_method_offset(body, "_attachRetryToLastAssistant")
fn = body[start:end]
# Match a `renderVerdictBadge(<something>.verdict, ...)` call inside
# the replay loop. Loose on whitespace + identifier so a future
@@ -208,8 +226,8 @@ def test_replay_renders_user_interjection_advisory_after_tool_block() -> None:
invocation regresses the queued-during-batch replay shape
silently."""
body = _APP_JS.read_text(encoding="utf-8")
start = body.index("Pane.prototype.replayHistory = function")
end = body.index("Pane.prototype._attachRetryToLastAssistant", start)
start = _pane_method_offset(body, "replayHistory")
end = _pane_method_offset(body, "_attachRetryToLastAssistant")
fn = body[start:end]
# The replay loop must invoke the shared helper, passing
# ``msg.advisories`` and a renderer that routes through
@@ -236,11 +254,48 @@ def test_replay_renders_user_interjection_advisory_after_tool_block() -> None:
_INDEX_HTML = Path(__file__).resolve().parent.parent / "turnstone/ui/static/index.html"
_STYLE_CSS = Path(__file__).resolve().parent.parent / "turnstone/ui/static/style.css"
# The Phase-8 D-chunk pins the absence of an unsafe DOM-write API
# in two regions of app.js. Spell the property name out of literal
# concatenation so the tooling that flags occurrences in code
# strings doesn't false-positive on the test source.
_UNSAFE_DOM_WRITE_RE = re.compile(r"\.inner" + r"HTML\s*=")
# Pins the absence of unsafe DOM-write and dynamic-code sinks. Spell
# the property/identifier names out of literal string concatenation so
# the tooling that flags occurrences in code strings doesn't
# false-positive on the test source.
#
# The pattern catches each of:
# * plain HTML-assignment — inner/outer-HTML to a value
# * concat HTML-assignment — inner/outer-HTML += value (the
# ``\+?`` makes the ``+`` optional so a regression switching the
# sink to concat-assignment doesn't bypass the lint)
# * insertAdjacent HTML — ``insertAdjacentHTML(...)`` (the
# ``HTML\(`` suffix excludes ``insertAdjacentElement``, which
# takes a DOM node and is not an XSS sink)
# * legacy doc-write — ``document`` + ``.write(...)``
# * string-to-code helpers — the JS ``ev`` + ``al`` builtin, the
# dynamic-Function constructor (``new`` + ``Function(...)``), and
# ``setTimeout``/``setInterval`` whose first arg is a string
# literal (function-first-arg forms remain unflagged)
#
# The trailing ``(?!=)`` negative-lookahead on the HTML assignments
# excludes ``===`` / ``==`` reads — only the write sinks are flagged.
#
# The scan in ``test_no_unsafe_code_sinks_in_static_assets`` runs the
# regex over the *entire file body* (not line-by-line) so that ``\s*``
# can span newlines and catch multi-line sinks like
# ``el.innerHTML\n = X``.
_UNSAFE_CODE_SINK_RE = re.compile(
r"\.(?:inner|outer)"
+ r"HTML\s*\+?=(?!=)"
+ r"|\.insertAdjacent"
+ r"HTML\s*\("
+ r"|"
+ r"document"
+ r"\."
+ r"write"
+ r"\("
+ r"|\b"
+ r"eval\s*\("
+ r"|\bnew\s+"
+ r"Function\s*\("
+ r"|\bset(?:Timeout|Interval)\s*\(\s*['\"`]"
)
def test_phase8_mcp_error_helpers_defined_in_app_js() -> None:
@@ -302,8 +357,8 @@ def test_phase8_appendtooloutput_dispatches_mcp_error_before_renderer() -> None:
interactive consent card replace the JSON dump; reverse the calls
and the user sees the raw error envelope as text again."""
body = _APP_JS.read_text(encoding="utf-8")
start = body.index("Pane.prototype.appendToolOutput = function")
end = body.index("Pane.prototype.", start + 10)
start = _pane_method_offset(body, "appendToolOutput")
end = _pane_method_offset(body, "sendMessage")
fn = body[start:end]
parse_idx = fn.find("tryParseMcpError(")
render_idx = fn.find("renderToolOutput(")
@@ -319,6 +374,119 @@ def test_phase8_appendtooloutput_dispatches_mcp_error_before_renderer() -> None:
)
_UTILS_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/utils.js"
_AUTH_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/auth.js"
_KB_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/kb.js"
_COORD_JS = (
Path(__file__).resolve().parent.parent / "turnstone/console/static/coordinator/coordinator.js"
)
_CONSOLE_ADMIN_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/admin.js"
_CONSOLE_GOVERNANCE_JS = (
Path(__file__).resolve().parent.parent / "turnstone/console/static/governance.js"
)
_CONSOLE_APP_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/app.js"
_UNSAFE_CODE_SINK_LINT_TARGETS = [
("turnstone/ui/static/app.js", _APP_JS),
("turnstone/shared_static/utils.js", _UTILS_JS),
("turnstone/shared_static/auth.js", _AUTH_JS),
("turnstone/shared_static/kb.js", _KB_JS),
("turnstone/console/static/coordinator/coordinator.js", _COORD_JS),
("turnstone/console/static/admin.js", _CONSOLE_ADMIN_JS),
("turnstone/console/static/governance.js", _CONSOLE_GOVERNANCE_JS),
("turnstone/console/static/app.js", _CONSOLE_APP_JS),
]
@pytest.mark.parametrize(
"label,path",
_UNSAFE_CODE_SINK_LINT_TARGETS,
ids=[label for label, _ in _UNSAFE_CODE_SINK_LINT_TARGETS],
)
def test_no_unsafe_code_sinks_in_static_assets(label: str, path: Path) -> None:
"""Whole-file pin: no direct DOM-write *or* dynamic-code sinks
in any of the static JS bundles that render LLM output, tool
results, operator-supplied data, or user input. Covers
inner/outer-HTML assignment (plain and concat), insertAdjacentHTML,
legacy doc-write, string-eval, dynamic-Function constructor, and
string-first-arg timer scheduling.
Two distinct cleanup postures across the targets:
1. **Strict DOM-construction** (``ui/static/app.js``,
``shared_static/utils.js``, ``shared_static/auth.js``,
``shared_static/kb.js``, ``coordinator.js`` chat entry,
``console/static/app.js``): renderer output routes through
``setMarkdown`` (or ``setSafeHtml`` for pre-baked HTML strings);
every other site uses ``createElement`` + ``textContent`` +
``append`` / ``replaceChildren``. Missing escapes are
structurally impossible — no HTML string is ever interpolated.
2. **Sink-free string-concat** (``console/static/admin.js``,
``console/static/governance.js``): operator-facing admin /
governance pages still build HTML via ``escapeHtml`` + string
concat, but the unsafe sink is off the call site (everything
routes through ``setSafeHtml``). XSS defence still depends on
every interpolated value going through escapeHtml; the lint
catches the sink but cannot catch a missing escape.
All admin-side bundles are now covered.
The regex covers inner/outer-HTML assignment (plain and
concat-assignment), ``insertAdjacentHTML``, legacy doc-write, and
the dynamic-code constructors (string-eval, dynamic-Function,
string-first-arg timer scheduling). ``insertAdjacentElement`` is
intentionally not flagged — it takes a DOM node, not a string.
Parametrized so each target is its own pytest case — a failure on
one file is attributed precisely without masking offenders in the
others.
Scans the whole file body (not line-by-line) so the regex's
``\\s*`` can span newlines and catch multi-line sinks like
``el.innerHTML\\n = X``. Match positions map back to line
numbers for the failure message."""
body = path.read_text(encoding="utf-8")
lines = body.splitlines()
offenders: list[tuple[int, str]] = []
for m in _UNSAFE_CODE_SINK_RE.finditer(body):
line_no = body.count("\n", 0, m.start()) + 1
offenders.append((line_no, lines[line_no - 1].rstrip()))
assert not offenders, (
f"Found {len(offenders)} unsafe code/DOM sink(s) in "
f"{label}:\n"
+ "\n".join(f" line {n}: {line}" for n, line in offenders[:10])
+ "\nUse DOM construction (createElement + textContent + "
"append/replaceChildren) or route renderer output through "
"setMarkdown() / setSafeHtml() in shared/utils.js."
)
def test_shared_utils_defines_set_markdown_helper() -> None:
"""The ``setMarkdown`` helper in ``shared/utils.js`` is the single
audited entry point for rendering markdown content into a DOM
element from ``app.js``. It parses ``renderMarkdown``'s output via
``DOMParser`` (avoiding the unsafe sink entirely) and runs
``postRenderMarkdown`` on the result. A refactor that drops or
renames it would break the two interactive call sites silently at
runtime."""
body = _UTILS_JS.read_text(encoding="utf-8")
assert "function setMarkdown(el, content)" in body, (
"shared/utils.js must define setMarkdown(el, content) — "
"app.js routes both renderer-output sites through this helper."
)
# The DOMParser path is what avoids the unsafe sink. The absence
# of the unsafe assignment inside the helper is pinned by the
# broader ``test_no_unsafe_code_sinks_in_static_assets`` scan
# above; pin DOMParser presence here too so a refactor that swaps
# to e.g. ``Range.createContextualFragment`` forces an explicit
# reviewer decision.
assert "DOMParser()" in body, (
"setMarkdown must parse via DOMParser, not the unsafe DOM-write "
"sink — that is what keeps the audit surface at one location."
)
def test_phase8_no_unsafe_dom_write_in_settings_panel() -> None:
"""Defensive XSS guard: the settings panel renders user-controlled
server names, scope strings, and timestamp values into the DOM.
@@ -332,7 +500,7 @@ def test_phase8_no_unsafe_dom_write_in_settings_panel() -> None:
# top-level keydown handler block).
end = body.index('document.addEventListener("keydown"', start)
section = body[start:end]
assert not _UNSAFE_DOM_WRITE_RE.search(section), (
assert not _UNSAFE_CODE_SINK_RE.search(section), (
"Section 15 must not assign to the unsafe DOM-write property — "
"server names and scope values flow through here and would be "
"XSS-injectable. Use textContent / DOM APIs instead."
@@ -340,7 +508,7 @@ def test_phase8_no_unsafe_dom_write_in_settings_panel() -> None:
def test_phase8_settings_button_in_index_html() -> None:
"""The gear-icon entry-point for the settings panel must remain
"""The gear-icon entry-point for the settings menu must remain
in the appbar's actions span. The console proxy IIFE prepends a
node pill to ``header.firstChild`` (turnstone/console/server.py:
202); our button is appended inside ``<span class='appbar-actions'>``
@@ -351,9 +519,10 @@ def test_phase8_settings_button_in_index_html() -> None:
"index.html must keep the #settings-btn — onclick handlers "
"and the consent badge target it by id."
)
assert 'onclick="openSettingsPanel()"' in body, (
"settings-btn must wire onclick=openSettingsPanel() — losing "
"the binding leaves the panel unreachable."
assert 'onclick="toggleSettingsMenu(this)"' in body, (
"settings-btn must wire onclick=toggleSettingsMenu(this) — "
"the gear opens a dropdown with MCP connections + Logout; "
"losing the binding leaves the menu unreachable."
)
# The button must live inside <span class="appbar-actions"> so the
# console proxy's header.insertBefore(pill, header.firstChild)
@@ -366,6 +535,76 @@ def test_phase8_settings_button_in_index_html() -> None:
)
def test_settings_menu_handlers_defined() -> None:
"""The gear-icon dropdown exposes a toggle/open/close trio that the
inline ``onclick="toggleSettingsMenu(this)"`` in index.html depends
on, plus the menu items themselves must wire to existing entry
points (``openSettingsPanel`` for MCP connections, ``logout`` for
sign-out). Pin all four so a rename or deletion fails loudly here
instead of silently leaving the gear's menu broken or wired to a
stale function."""
body = _APP_JS.read_text(encoding="utf-8")
for name in [
"function toggleSettingsMenu",
"function openSettingsMenu",
"function closeSettingsMenu",
]:
assert name in body, f"Missing required handler: {name}"
# Bound to the settings-menu region so we don't accidentally match
# an unrelated openSettingsPanel/logout call elsewhere in the file.
start = body.index("function openSettingsMenu(")
end = body.index("function closeSettingsMenu(", start)
section = body[start:end]
assert "openSettingsPanel()" in section, (
"Settings menu's MCP-connections item must call openSettingsPanel() "
"— otherwise the existing settings overlay is unreachable from the "
"new dropdown."
)
assert "logout()" in section, (
"Settings menu's Logout item must call logout() — that's the "
"shared auth.js entry point that clears the cookie + session state."
)
def test_dashboard_overlay_is_region_not_dialog() -> None:
"""The dashboard overlay must be role='region' (not role='dialog' +
aria-modal='true'). The role downgrade is what allows ui-header to
stay interactive while the dashboard is open — see the comment at
showDashboard() in app.js. A revert to role='dialog' + aria-modal
would re-trap focus and break the gear/theme buttons + the console
proxy's node-picker pill while the dashboard is open."""
body = _INDEX_HTML.read_text(encoding="utf-8")
idx = body.index('id="dashboard"')
# Bound to ~600 chars after the tag so we only check this element's
# attributes — same shape as test_phase8_settings_modal_in_index_html.
chunk = body[idx : idx + 600]
assert 'role="region"' in chunk, (
"dashboard must be role='region' — see showDashboard() comment."
)
assert "aria-modal" not in chunk, (
"dashboard must NOT be aria-modal — re-trapping focus breaks "
"the appbar's interactive controls (theme toggle, settings menu, "
"proxy node-picker pill) while the dashboard is open."
)
def test_close_settings_menu_resets_aria() -> None:
"""closeSettingsMenu must reset aria-expanded='false' AND remove
aria-controls from the gear trigger. Without the reset the gear
keeps reporting 'expanded' to assistive tech after the menu closes;
without the removal aria-controls points at a dead DOM id."""
body = _APP_JS.read_text(encoding="utf-8")
start = body.index("function closeSettingsMenu(")
# Bound to ~600 chars so we don't catch unrelated handlers.
section = body[start : start + 600]
assert 'setAttribute("aria-expanded", "false")' in section, (
"closeSettingsMenu must set aria-expanded='false' on the gear."
)
assert 'removeAttribute("aria-controls")' in section, (
"closeSettingsMenu must remove aria-controls from the gear."
)
def test_phase8_settings_modal_in_index_html() -> None:
"""Both the settings overlay and the revoke-confirmation overlay
must remain in the modal area. The Escape-key deferral list in
@@ -401,7 +640,7 @@ def test_phase8_xss_safe_render_in_build_mcp_error_embed() -> None:
end_match = re.search(r"\n}\n", rest)
assert end_match is not None
fn = rest[: end_match.end()]
assert not _UNSAFE_DOM_WRITE_RE.search(fn), (
assert not _UNSAFE_CODE_SINK_RE.search(fn), (
"buildMcpErrorEmbed must not use the unsafe-DOM-write API — "
"server names and detail strings flow through here. An "
"adversarial server name must render harmlessly via "
@@ -455,3 +694,514 @@ def test_phase8_consent_url_prefix_check_in_click_handler() -> None:
'"javascript:" injection) would be passed straight to '
"window.open."
)
# ---------------------------------------------------------------------------
# Post-var-sweep invariants — added by chore/interactive-var-sweep
# ---------------------------------------------------------------------------
#
# After the whole-file var → const/let sweep across these 7 bundles, three
# guards keep the post-sweep state honest:
# 1. ``node --check`` per bundle catches parse-level regressions on any
# future edit (mis-balanced braces, stray tokens) before they reach
# the browser.
# 2. A var-free static assertion pins the keyword sweep — any future
# ``var`` declaration in these bundles fails CI loudly.
# 3. A static const-reassign guard catches the specific bug class that
# shipped through the original sweep (``const X = …; … X = …``
# throws ``TypeError`` only at call-time, which ``node --check``
# does not surface). This is the same paren/string/regex-aware
# reassignment check the sweep walker uses.
#
# A fourth guard runs ``_redactApiKeys`` via ``node -e`` as a runtime
# smoke; the function is pure (no DOM dependency) so it transplants
# cleanly into a standalone node invocation.
import subprocess # noqa: E402
def _slice_balanced_body(body: str, anchor: int) -> str | None:
"""Slice ``body`` from ``anchor`` (which must point at or just before
the opening ``{`` of a block) up to and including the matching ``}``.
Tracks brace depth + string state so the slice is robust to comment
growth and arbitrary body reorganisation. Returns ``None`` if the
matching brace isn't found within a reasonable window.
Used to slice JS handler / function bodies for static assertions
without committing to a fixed character window."""
n = len(body)
i = body.find("{", anchor)
if i == -1 or i - anchor > 200:
return None
depth = 0
in_str: str | None = None
start = i
while i < n and i - start < 8000:
ch = body[i]
if in_str:
if ch == "\\" and i + 1 < n:
i += 2
continue
if ch == in_str:
in_str = None
i += 1
continue
if ch in ('"', "'", "`"):
in_str = ch
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return body[start : i + 1]
i += 1
return None
def _slice_listener_body(body: str, event_name: str) -> str | None:
"""Return the handler-function body registered via
``addEventListener("<event_name>", function ...)``, sliced by
matching braces (robust to comment / formatting growth)."""
anchor = body.find(f'addEventListener("{event_name}"')
if anchor == -1:
return None
return _slice_balanced_body(body, anchor)
def _slice_function_body(body: str, fn_name: str) -> str | None:
"""Return the body of ``function <fn_name>(...) { ... }`` sliced by
matching braces."""
m = re.search(r"function\s+" + re.escape(fn_name) + r"\s*\(", body)
if m is None:
return None
return _slice_balanced_body(body, m.start())
_REPO_ROOT = Path(__file__).resolve().parent.parent
# Bundles that completed the var → const/let sweep. Add a new JS file
# here only after it has itself been swept — the var-free + const-reassign
# guards below will otherwise fail loudly on any pre-sweep `var` it
# contains. coordinator.js is intentionally excluded (already modern;
# 3 surviving `var` are by design per the sweep briefing).
_SWEPT_BUNDLES = [
_REPO_ROOT / "turnstone/ui/static/app.js",
_REPO_ROOT / "turnstone/console/static/admin.js",
_REPO_ROOT / "turnstone/console/static/governance.js",
_REPO_ROOT / "turnstone/console/static/app.js",
_REPO_ROOT / "turnstone/shared_static/auth.js",
_REPO_ROOT / "turnstone/shared_static/kb.js",
_REPO_ROOT / "turnstone/shared_static/utils.js",
]
@pytest.mark.parametrize("bundle", _SWEPT_BUNDLES, ids=lambda p: p.name)
def test_swept_bundle_parses(bundle: Path) -> None:
"""``node --check`` each swept bundle. Catches syntax-level
regressions (a future edit that drops a brace, mis-balances a
string, etc.) before they reach the browser. Skipped silently if
``node`` is not on PATH so local dev without Node still passes."""
node = "node"
try:
proc = subprocess.run(
[node, "--check", str(bundle)],
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
pytest.skip("node binary not available on PATH")
assert proc.returncode == 0, f"node --check failed for {bundle.name}:\n{proc.stderr}"
@pytest.mark.parametrize("bundle", _SWEPT_BUNDLES, ids=lambda p: p.name)
def test_swept_bundle_has_no_var_decl(bundle: Path) -> None:
"""Pin the var-free post-sweep state across all 7 bundles. A
future ``var`` declaration here fails CI loudly so the sweep
doesn't regress in patches."""
body = bundle.read_text(encoding="utf-8")
# Line-start ``var`` declarations.
line_start = re.findall(r"^\s*var\s+\w", body, re.MULTILINE)
# ``for (var i …)`` counters anywhere on a line.
for_init = re.findall(r"\bfor\s*\(\s*var\s+", body)
stray = line_start + for_init
assert not stray, (
f"{bundle.name}: {len(stray)} stray ``var`` declarations found "
f"after the var-sweep — the post-sweep invariant is broken. "
f"Convert to ``const``/``let``."
)
def _strip_strings_and_line_comments(line: str) -> str:
"""Return ``line`` with string-literal contents and ``// …`` tails
removed, so simple regex-based scanning can't be tricked by an
identifier embedded in a CSS class name or HTML attribute.
Mirrors the sweep walker's helper of the same purpose."""
out: list[str] = []
i = 0
n = len(line)
in_str: str | None = None
while i < n:
ch = line[i]
if in_str:
if ch == "\\" and i + 1 < n:
i += 2
continue
if ch == in_str:
in_str = None
i += 1
continue
if ch in ('"', "'", "`"):
in_str = ch
i += 1
continue
if ch == "/" and i + 1 < n and line[i + 1] == "/":
break
out.append(ch)
i += 1
return "".join(out)
_REGEX_OK_KEYWORDS = frozenset(
{
"return",
"throw",
"typeof",
"instanceof",
"in",
"of",
"new",
"delete",
"void",
"do",
"yield",
"await",
"case",
"else",
}
)
def _is_regex_context_at(text: str, slash_pos: int) -> bool:
"""``text[slash_pos]`` is ``/``. Return ``True`` if it starts a regex
literal vs the division operator, by inspecting the previous significant
char (skipping whitespace and ``/* */`` block comments going backward)."""
i = slash_pos - 1
while i >= 0:
ch = text[i]
if ch.isspace():
i -= 1
continue
if ch == "/" and i >= 1 and text[i - 1] == "*":
open_i = text.rfind("/*", 0, i - 1)
if open_i == -1:
return True
i = open_i - 1
continue
if ch.isalnum() or ch in "_$":
k = i
while k >= 0 and (text[k].isalnum() or text[k] in "_$"):
k -= 1
ident = text[k + 1 : i + 1]
return ident in _REGEX_OK_KEYWORDS
return ch not in ")]"
return True
def _consume_regex_at(text: str, start: int) -> tuple[int, bool]:
"""Consume regex literal starting at ``text[start] == '/'``. Returns
``(end_pos, ok)``. Handles backslash escapes and ``[...]`` char classes
(a ``/`` inside a class doesn't end the regex)."""
n = len(text)
i = start + 1
in_class = False
while i < n:
ch = text[i]
if ch == "\n":
return start, False
if ch == "\\" and i + 1 < n:
i += 2
continue
if ch == "[":
in_class = True
elif ch == "]":
in_class = False
elif ch == "/" and not in_class:
i += 1
while i < n and text[i] in "gimsuyd":
i += 1
return i, True
i += 1
return start, False
def _build_brace_map(
text: str,
) -> tuple[dict[int, int], list[int]]:
"""Walk ``text`` once. Returns ``(open_to_close, line_starts)`` where
``open_to_close[open_off] = close_off`` for matched braces, and
``line_starts[i]`` is the char offset where line index ``i`` (0-based)
begins. Robust to JS regex literals, strings, ``//`` and ``/* */``
comments."""
n = len(text)
line_starts = [0]
for i, ch in enumerate(text):
if ch == "\n":
line_starts.append(i + 1)
stack: list[int] = []
open_to_close: dict[int, int] = {}
in_str: str | None = None
in_comment: str | None = None
i = 0
while i < n:
ch = text[i]
if in_comment == "//":
if ch == "\n":
in_comment = None
i += 1
continue
if in_comment == "/*":
if ch == "*" and i + 1 < n and text[i + 1] == "/":
in_comment = None
i += 2
continue
i += 1
continue
if in_str:
if ch == "\\" and i + 1 < n:
i += 2
continue
if ch == in_str:
in_str = None
i += 1
continue
if ch in ('"', "'", "`"):
in_str = ch
i += 1
continue
if ch == "/" and i + 1 < n:
if text[i + 1] == "/":
in_comment = "//"
i += 2
continue
if text[i + 1] == "*":
in_comment = "/*"
i += 2
continue
if _is_regex_context_at(text, i):
end, ok = _consume_regex_at(text, i)
if ok:
i = end
continue
if ch == "{":
stack.append(i)
elif ch == "}" and stack:
open_to_close[stack.pop()] = i
i += 1
return open_to_close, line_starts
def _offset_to_line(line_starts: list[int], off: int) -> int:
lo, hi = 0, len(line_starts)
while lo + 1 < hi:
mid = (lo + hi) // 2
if line_starts[mid] <= off:
lo = mid
else:
hi = mid
return lo
def _enclosing_block(
decl_offset: int,
open_to_close: dict[int, int],
line_starts: list[int],
total_lines: int,
) -> tuple[int, int]:
"""Innermost block containing ``decl_offset``. ``(start_line, end_line)``
inclusive. Returns ``(0, total_lines - 1)`` when at top-level."""
candidates = [(op, cl) for op, cl in open_to_close.items() if op < decl_offset < cl]
if not candidates:
return 0, total_lines - 1
op, cl = max(candidates, key=lambda x: x[0])
return (
_offset_to_line(line_starts, op),
_offset_to_line(line_starts, cl),
)
@pytest.mark.parametrize("bundle", _SWEPT_BUNDLES, ids=lambda p: p.name)
def test_swept_bundle_has_no_const_reassign(bundle: Path) -> None:
"""For each ``const X = …`` declaration, fail if X is reassigned
*within the same block scope* (``X = …``, ``X +=``, ``X++``, ``++X``,
etc., with lookbehind to skip ``obj.X = …`` property writes). Block
scope is found by brace-tracking with regex/string/comment awareness,
so a same-named ``let X`` in an unrelated function doesn't
false-positive against a ``const X`` in this one. Caught the
original ``_redactApiKeys`` shipped bug (postfix ``redacted = …``)
and a sibling ``++_paneCounter`` prefix-increment that the first
iteration of this guard missed — both were ``TypeError`` at
call-time, invisible to ``node --check`` and to whole-file
keyword scans."""
body = bundle.read_text(encoding="utf-8")
lines = body.splitlines()
open_to_close, line_starts = _build_brace_map(body)
const_decl = re.compile(r"^(\s*)const\s+(\w+)\b")
bugs: list[tuple[int, str, int, str, str]] = []
for idx, line in enumerate(lines):
m = const_decl.match(line)
if not m:
continue
name = m.group(2)
decl_offset = line_starts[idx] + len(m.group(1))
start_line, end_line = _enclosing_block(decl_offset, open_to_close, line_starts, len(lines))
# Reassignment forms: postfix `X++`/`X--`, prefix `++X`/`--X`,
# compound `X +=`/`X -=`/.../`X ??=`, plain `X =` (not ==/===).
# Negative lookbehind skips property writes (`obj.X = …`).
pat = re.compile(
r"(?:"
r"(?<![A-Za-z0-9_$])(?:\+\+|--)" # prefix `++X` / `--X`
+ re.escape(name)
+ r"(?![A-Za-z0-9_$])"
+ r"|"
r"(?<![A-Za-z0-9_$.])"
+ re.escape(name)
+ r"\s*(?:\+\+|--|" # postfix `X++` / `X--`
+ r"(?:\+|-|\*\*?|/|%|&&?|\|\|?|\^|<<|>>>?|\?\?)=|" # compound
+ r"=(?!=))" # plain `X =`
+ r")"
)
decl_other = re.compile(
r"(?:^\s*(?:let|const|var)\s+|\bfor\s*\(\s*(?:let|const|var)\s+)"
+ re.escape(name)
+ r"\b"
)
param = re.compile(r"\((?:[^()]*?,\s*)?" + re.escape(name) + r"\s*[,)]")
for j in range(start_line, end_line + 1):
if j == idx:
continue
stripped = _strip_strings_and_line_comments(lines[j])
if not pat.search(stripped):
continue
if decl_other.search(stripped):
continue
if param.search(stripped):
cleaned = param.sub("(", stripped)
if not pat.search(cleaned):
continue
bugs.append((idx + 1, name, j + 1, lines[idx].strip(), lines[j].strip()))
break
if bugs:
detail = "\n".join(
f" {bundle.name}:{decl_ln} const {name} reassigned at "
f"{bundle.name}:{reass_ln}\n decl: {decl_text}\n reass: {reass_text}"
for decl_ln, name, reass_ln, decl_text, reass_text in bugs[:3]
)
suffix = f"\n ... and {len(bugs) - 3} more" if len(bugs) > 3 else ""
raise AssertionError(
f"const declaration(s) reassigned within block scope. "
f"Change to `let` or eliminate the reassignment:\n{detail}{suffix}"
)
def test_redact_api_keys_runtime_smoke() -> None:
"""Runtime smoke for ``_redactApiKeys``. The function is pure — no
DOM dependency — so it transplants cleanly into a standalone
``node -e`` invocation. This is the bit that would have caught
the original ``const redacted`` bug (which ``node --check`` and a
pure-static keyword scan both miss; the ``TypeError`` only fires
at call-time)."""
body = _APP_JS.read_text(encoding="utf-8")
m = re.search(
r"function _redactApiKeys\(text\) \{.*?\n\}\n",
body,
re.DOTALL,
)
assert m is not None, "_redactApiKeys not found in app.js"
fn = m.group(0)
script = (
fn
+ "\nconst q = _redactApiKeys('https://x?api_key=abc&u=foo');\n"
+ 'if (q !== "https://x?api_key=***&u=foo") '
+ "throw new Error('query-string redact failed: ' + q);\n"
+ 'const j = _redactApiKeys(\'{"api_key":"abc"}\');\n'
+ 'if (j !== \'{"api_key":"***"}\') '
+ "throw new Error('json-style redact failed: ' + j);\n"
)
try:
proc = subprocess.run(
["node", "-e", script],
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
pytest.skip("node binary not available on PATH")
assert proc.returncode == 0, (
f"_redactApiKeys runtime smoke failed. stdout={proc.stdout!r} stderr={proc.stderr!r}"
)
def test_beforeunload_closes_sse_connections() -> None:
"""Pin the multi-pane refresh mitigation: the ``beforeunload``
handler closes ``globalEvtSource`` and every pane's ``evtSource``
before the page navigates away, freeing the browser's HTTP/1.1
6-connection-per-host budget so the refresh document fetch can
open a slot. Without this handler, refresh at MAX_PANES hangs
in Chrome and leaves Firefox stuck on the loading state.
This is a tactical mitigation; the real fix is the console SSE
fan-in (one connection per page). Pinning the handler here
prevents a future refactor from silently dropping it before
the fan-in lands."""
body = _APP_JS.read_text(encoding="utf-8")
handler = _slice_listener_body(body, "beforeunload")
assert handler is not None, "beforeunload handler missing — refresh at MAX_PANES will hang."
assert "globalEvtSource" in handler, "beforeunload handler must reference globalEvtSource."
assert ".close()" in handler, "beforeunload handler must close at least one connection."
assert "panes" in handler, "beforeunload handler must reference the panes registry."
# Either bare `evtSource.close()` or `disconnectSSE()` (which closes +
# clears pending timers) is acceptable for per-pane teardown — pin the
# behaviour, not the implementation.
assert ".disconnectSSE()" in handler or ".evtSource.close()" in handler, (
"beforeunload handler must tear down per-pane SSEs "
"(`Pane.disconnectSSE()` is preferred — it also clears pending timers)."
)
def test_dead_sse_defensive_reconnect_registered() -> None:
"""Pin the defensive reconnect: visibilitychange + focus listeners
must re-establish SSE connections that were closed by beforeunload
when the navigation didn't actually complete (e.g. another
beforeunload handler's "Are you sure?" dialog dismissed). Without
these, the page stays alive with dead SSEs and no automatic
recovery — UI silently stops receiving events.
The two listeners cover different cancellation shapes: visibilitychange
catches hide/show; focus catches modal/browser-UI/OS-level focus loss
and return. Both call the same idempotent reconnect helper."""
body = _APP_JS.read_text(encoding="utf-8")
# Both event registrations must be present.
assert 'addEventListener("visibilitychange"' in body, (
"visibilitychange listener missing — defensive reconnect won't fire on tab return."
)
assert 'addEventListener("focus"' in body, (
"focus listener missing — defensive reconnect won't catch "
"modal-dismissed cancellation paths."
)
# The reconnect helper must inspect EventSource state and call the
# existing connect helpers. Slice the helper's body by walking the
# matching `}` so the assertions are robust to comment growth + body
# reorganisation.
helper_body = _slice_function_body(body, "_reconnectDeadSSEs")
assert helper_body is not None, (
"_reconnectDeadSSEs helper missing — reconnect logic must live in "
"a named function the listeners can share."
)
assert "EventSource" in helper_body, (
"_reconnectDeadSSEs must inspect EventSource state so live or "
"CONNECTING sockets aren't disrupted."
)
assert "connectGlobalSSE()" in helper_body, (
"_reconnectDeadSSEs must reconnect the global SSE when closed."
)
assert "connectSSE(" in helper_body, "_reconnectDeadSSEs must reconnect dead per-pane SSEs."
+33
View File
@@ -83,6 +83,39 @@ class TestIsPublicPath:
def test_shared_static_public(self):
assert is_public_path("/shared/base.css") is True
# Console proxy: a public proxied path must still be public, otherwise
# the login modal can never re-authenticate from inside a ``/node/{id}/``
# proxied page once the cookie expires.
def test_proxy_v1_login_public(self):
assert is_public_path("/node/node-a/v1/api/auth/login") is True
def test_proxy_no_v1_login_public(self):
assert is_public_path("/node/node-a/api/auth/login") is True
def test_proxy_v1_status_public(self):
assert is_public_path("/node/node-a/v1/api/auth/status") is True
def test_proxy_v1_setup_public(self):
assert is_public_path("/node/node-a/v1/api/auth/setup") is True
def test_proxy_v1_logout_public(self):
assert is_public_path("/node/node-a/v1/api/auth/logout") is True
def test_proxy_v1_oidc_authorize_public(self):
assert is_public_path("/node/node-a/v1/api/auth/oidc/authorize") is True
def test_proxy_v1_oidc_callback_public(self):
assert is_public_path("/node/node-a/v1/api/auth/oidc/callback") is True
def test_proxy_v1_workstreams_still_not_public(self):
"""Proxy prefix must not turn protected paths into public ones."""
assert is_public_path("/node/node-a/v1/api/workstreams") is False
def test_proxy_v1_refresh_still_requires_auth(self):
"""Refresh isn't in PUBLIC_PATHS — the caller must already have
a valid cookie. Proxy-prefix shouldn't change that."""
assert is_public_path("/node/node-a/v1/api/auth/refresh") is False
# ---------------------------------------------------------------------------
# TestRequiredRole
@@ -0,0 +1,66 @@
"""ChatSession interactivity flag tests (Phase 9).
Validates that ``ChatSession._is_interactive_for_consent`` is computed
correctly from ``client_type`` on construction. This is the front of
the Phase 9 plumb-through: the flag flows from here to
``_dispatch_pool_sync`` to the structured-error → pending-consent
write path.
"""
from __future__ import annotations
from tests._session_helpers import make_session
from turnstone.prompts import INTERACTIVE_CONSENT_CLIENT_TYPES, ClientType
def test_web_is_interactive() -> None:
s = make_session(client_type=ClientType.WEB)
assert s._is_interactive_for_consent is True
def test_cli_is_interactive() -> None:
s = make_session(client_type=ClientType.CLI)
assert s._is_interactive_for_consent is True
def test_chat_is_not_interactive() -> None:
# Discord / Slack adapters cannot drive a browser redirect from
# inside the channel — consent prompts must be deferred to the
# dashboard badge.
s = make_session(client_type=ClientType.CHAT)
assert s._is_interactive_for_consent is False
def test_scheduled_is_not_interactive() -> None:
# The scheduler runs autonomously; the user isn't online to
# complete the OAuth redirect.
s = make_session(client_type=ClientType.SCHEDULED)
assert s._is_interactive_for_consent is False
def test_interactive_set_matches_module_constant() -> None:
# Pin the module-level frozenset against the flag computation —
# a future reorganisation that drifts the set vs the per-session
# logic would silently break the gating.
for ct in ClientType:
s = make_session(client_type=ct)
assert s._is_interactive_for_consent == (ct in INTERACTIVE_CONSENT_CLIENT_TYPES), ct
def test_default_client_type_is_cli_interactive() -> None:
# Defaults preserved — make_session uses ChatSession's default
# which is CLI. Sanity check that the default user experience
# stays interactive-for-consent.
s = make_session()
assert s._client_type == ClientType.CLI
assert s._is_interactive_for_consent is True
def test_scheduled_env_file_exists() -> None:
"""The SCHEDULED env module must exist; otherwise
``compose_system_message`` for a scheduled session would 500."""
from turnstone.prompts import _load
text = _load("env/scheduled.md")
assert "Output Environment" in text
assert "consent" in text.lower()
+215
View File
@@ -0,0 +1,215 @@
"""Unit tests for :class:`turnstone.core.child_event_bus.ChildEventBus`.
The bus is the in-process wakeup primitive for ``wait_for_workstream``
(see :mod:`turnstone.console.coordinator_client`). It's a small dict
of ws_id → set[threading.Event] under a lock — focused tests for
register/notify symmetry, no-subscriber notify, multi-waiter fan-out,
multi-child waiter, and concurrent register/notify (smoke). End-to-end
integration with the dispatch sink lives in
``test_coordinator_adapter.py`` and ``test_coordinator_client.py``.
"""
from __future__ import annotations
import threading
import time
import pytest
from turnstone.core.child_event_bus import ChildEventBus
def test_register_returns_event_that_starts_unset() -> None:
"""A waiter must not see leftover state from before it registered —
a fresh wait should always block until the first notify."""
bus = ChildEventBus()
event = bus.register_waiter(["ws-1"])
assert isinstance(event, threading.Event)
assert not event.is_set()
def test_notify_wakes_waiter_on_matching_ws_id() -> None:
bus = ChildEventBus()
event = bus.register_waiter(["ws-1"])
bus.notify("ws-1")
assert event.is_set()
def test_notify_does_not_wake_waiter_on_unrelated_ws_id() -> None:
"""Different ws_ids must keep independent waiter sets — a notify on
a stranger ws can't wake the wait or the bus stops being keyed."""
bus = ChildEventBus()
event = bus.register_waiter(["ws-1"])
bus.notify("ws-other")
assert not event.is_set()
def test_notify_with_no_subscribers_is_noop() -> None:
"""The dispatch sink calls notify on every translated event; the
steady state has no wait tool active. Must not raise."""
bus = ChildEventBus()
bus.notify("ws-nobody-cares") # no exception
def test_multi_waiter_each_gets_independent_event() -> None:
"""Two waits on the same ws_id must wake independently — clearing
one Event must not silence the other."""
bus = ChildEventBus()
e1 = bus.register_waiter(["ws-1"])
e2 = bus.register_waiter(["ws-1"])
assert e1 is not e2
bus.notify("ws-1")
assert e1.is_set()
assert e2.is_set()
def test_multi_child_waiter_fires_on_any_listed_ws_id() -> None:
"""A wait on [A, B, C] returns a single Event registered against
all three. Notify on ANY of A/B/C must wake the wait — the
caller's snapshot re-read disambiguates which one changed."""
bus = ChildEventBus()
event = bus.register_waiter(["ws-a", "ws-b", "ws-c"])
bus.notify("ws-b")
assert event.is_set()
def test_unregister_removes_event_from_all_listed_ws_ids() -> None:
"""After unregister, notify on any of the previously-watched ws_ids
must NOT wake the Event — leaks would mean every future notify on
that ws_id wakes a long-dead wait."""
bus = ChildEventBus()
event = bus.register_waiter(["ws-a", "ws-b"])
bus.unregister_waiter(["ws-a", "ws-b"], event)
bus.notify("ws-a")
bus.notify("ws-b")
assert not event.is_set()
def test_unregister_is_idempotent() -> None:
"""A double-unregister must silently no-op — finally blocks may
run twice in odd shutdown paths, the bus must not raise."""
bus = ChildEventBus()
event = bus.register_waiter(["ws-1"])
bus.unregister_waiter(["ws-1"], event)
bus.unregister_waiter(["ws-1"], event) # no exception
def test_unregister_pops_empty_buckets() -> None:
"""Empty per-ws_id buckets must be popped so a long-lived bus
doesn't accumulate dead keys after many waits have churned through.
Reaches into the private state — the property is structural, not
behavioral, so the assertion is also."""
bus = ChildEventBus()
event = bus.register_waiter(["ws-1"])
assert "ws-1" in bus._waiters
bus.unregister_waiter(["ws-1"], event)
assert "ws-1" not in bus._waiters
def test_unregister_keeps_bucket_with_remaining_waiters() -> None:
"""Removing one waiter from a multi-waiter bucket must not drop
the others — popping the bucket would silently disable notifies
for every concurrent wait on the same ws_id."""
bus = ChildEventBus()
e1 = bus.register_waiter(["ws-1"])
e2 = bus.register_waiter(["ws-1"])
bus.unregister_waiter(["ws-1"], e1)
bus.notify("ws-1")
assert not e1.is_set()
assert e2.is_set()
def test_empty_and_falsy_ws_ids_are_skipped_on_register() -> None:
"""Defensive: ``wait_for_workstream`` cleans its inputs but the bus
is reachable from other callers in future use; falsy ids should be
silently dropped, not registered against an empty-string key."""
bus = ChildEventBus()
event = bus.register_waiter(["", "ws-1", ""])
# Only the real ws_id should bucket the waiter.
assert list(bus._waiters.keys()) == ["ws-1"]
bus.notify("") # no crash, no spurious wake
assert not event.is_set()
bus.notify("ws-1")
assert event.is_set()
def test_notify_wakes_waiter_blocking_on_event_wait() -> None:
"""End-to-end wake-up latency: a wait blocked on ``Event.wait``
must return promptly after a notify on a watched ws_id. This is
the property that retires the 0.5s polling cadence."""
bus = ChildEventBus()
event = bus.register_waiter(["ws-1"])
woken_at = [0.0]
def _waiter() -> None:
event.wait(timeout=2.0)
woken_at[0] = time.monotonic()
t = threading.Thread(target=_waiter, daemon=True)
t.start()
# Give the waiter a beat to enter Event.wait, then notify.
time.sleep(0.05)
notified_at = time.monotonic()
bus.notify("ws-1")
t.join(timeout=1.0)
assert not t.is_alive(), "waiter did not wake within 1s of notify"
# Latency budget is generous; the contract is "well under the legacy
# 0.5s poll cadence", not microsecond timing.
assert woken_at[0] - notified_at < 0.2
def test_clear_before_check_race_does_not_lose_wake() -> None:
"""The wait-loop pattern is ``clear(); snapshot(); ...; wait()``.
A notify between clear and wait must leave the Event set, so the
next wait returns immediately and the loop re-snapshots. Same
standard subscribe/check race the wait loop guards against."""
bus = ChildEventBus()
event = bus.register_waiter(["ws-1"])
# Simulate wait-loop ordering: clear, then notify "between" clear
# and the next wait.
event.clear()
bus.notify("ws-1")
# The next wait must return True immediately (set is sticky until
# the next clear).
assert event.wait(timeout=0.1) is True
def test_concurrent_register_and_notify_is_safe() -> None:
"""Smoke test: many threads registering / notifying / unregistering
in parallel must not raise or deadlock. Doesn't assert specific
interleavings — only structural safety of the lock discipline."""
bus = ChildEventBus()
stop = threading.Event()
errors: list[BaseException] = []
def _worker(ws_id: str) -> None:
try:
for _ in range(200):
if stop.is_set():
return
ev = bus.register_waiter([ws_id])
bus.notify(ws_id)
bus.unregister_waiter([ws_id], ev)
except BaseException as e: # noqa: BLE001
errors.append(e)
threads = [threading.Thread(target=_worker, args=(f"ws-{i}",), daemon=True) for i in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5.0)
stop.set()
assert not errors, f"worker threads raised: {errors!r}"
# All buckets should have been popped (every register paired with
# unregister).
assert bus._waiters == {}
@pytest.mark.parametrize("ws_id", ["", None])
def test_notify_silently_ignores_falsy_ws_id(ws_id: object) -> None:
"""Defensive: the dispatch sink already guards against empty
ws_ids, but a falsy slip-through must not raise."""
bus = ChildEventBus()
event = bus.register_waiter(["ws-1"])
bus.notify(ws_id) # type: ignore[arg-type]
assert not event.is_set()
+35
View File
@@ -50,6 +50,41 @@ def test_load_config_invalid_toml(tmp_path):
assert load_config() == {}
def test_load_config_warns_when_world_readable(tmp_path, caplog):
"""Secrets in config.toml — warn if anyone but the owner can read it."""
import logging
import os
_reset_cache()
cfg = tmp_path / "config.toml"
cfg.write_text('[database]\nurl = "postgresql+psycopg://u:secret@h/d"\n')
os.chmod(cfg, 0o644)
set_config_path(str(cfg))
with caplog.at_level(logging.WARNING, logger="turnstone.core.config"):
load_config()
messages = [r.getMessage() for r in caplog.records]
assert any("group/world-readable" in m for m in messages)
def test_load_config_quiet_when_mode_0600(tmp_path, caplog):
import logging
import os
_reset_cache()
cfg = tmp_path / "config.toml"
cfg.write_text('[database]\nurl = "postgresql+psycopg://u:secret@h/d"\n')
os.chmod(cfg, 0o600)
set_config_path(str(cfg))
with caplog.at_level(logging.WARNING, logger="turnstone.core.config"):
load_config()
messages = [r.getMessage() for r in caplog.records]
assert not any("group/world-readable" in m for m in messages)
def test_load_config_caches(tmp_path):
_reset_cache()
cfg = tmp_path / "config.toml"
+210
View File
@@ -3,11 +3,13 @@
import asyncio
import json
import queue
from typing import Any
from unittest.mock import MagicMock
import pytest
from turnstone.console.collector import ClusterCollector, NodeSnapshot
from turnstone.console.server import _PROXY_AUTH_LOCAL_HANDLERS
# Shared test auth — JWT-based
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
@@ -149,6 +151,78 @@ class TestCollectorDiscovery:
assert c._nodes["node-a"].started == 1234567890.0
class TestCollectorNotifyWireIn:
"""NotifyDispatcher-driven discovery — reactive node visibility."""
def test_start_subscribes_to_services_channel(self):
# Stub dispatcher records subscriptions without spawning threads.
class _StubDispatcher:
def __init__(self):
self.subscriptions: list[tuple[str, Any]] = []
def subscribe(self, channel, handler):
self.subscriptions.append((channel, handler))
return lambda: None
stub = _StubDispatcher()
storage = MockStorage()
c = ClusterCollector(
storage=storage,
discovery_interval=999,
notify_dispatcher=stub,
)
try:
c.start()
assert len(stub.subscriptions) == 1
channel, handler = stub.subscriptions[0]
assert channel == "services"
assert handler == c._on_services_notify
finally:
c.stop()
def test_no_dispatcher_means_no_subscribe(self):
# Collector without a dispatcher (single-node / SQLite dev) just
# falls back to the 60 s discovery-loop polling — no error.
c = _make_collector(MockStorage())
try:
c.start()
assert c._notify_unsubscribe is None
finally:
c.stop()
def test_on_notify_runs_discovery(self):
# Construct a synthetic Notify and invoke the handler directly —
# asserts the wire-in delegates back to ``_discover_nodes``.
from turnstone.core.storage._notify import Notify
storage = MockStorage()
c = _make_collector(storage)
c._running = True # bypass start() so we don't spawn threads
q: queue.Queue[dict[str, Any]] = queue.Queue()
c.register_listener(q)
storage.services = [
{"service_id": "node-z", "url": "http://z:8080", "metadata": "{}"},
]
c._on_services_notify(Notify(channel="services", payload="{}", pid=0))
event = q.get_nowait()
assert event["type"] == "node_joined"
assert event["node_id"] == "node-z"
def test_on_notify_when_not_running_is_noop(self):
# If a stray notify arrives after stop, the handler doesn't run
# discovery on a half-torn-down collector.
from turnstone.core.storage._notify import Notify
storage = MockStorage()
storage.services = [{"service_id": "node-y", "url": "http://y:8080", "metadata": "{}"}]
c = _make_collector(storage)
# _running stays False (never called start()).
c._on_services_notify(Notify(channel="services", payload="{}", pid=0))
assert c.get_overview()["nodes"] == 0
class TestCollectorSnapshot:
"""Applying node_snapshot SSE events."""
@@ -1489,6 +1563,142 @@ class TestConsoleProxy:
assert sse_mock.await_count == 1
assert sse_mock.await_args.kwargs.get("use_service_auth") is False
# -------------------------------------------------------------------
# Proxied auth endpoints — handled locally by the console, not
# forwarded to the upstream node. Cases derive directly from
# ``_PROXY_AUTH_LOCAL_HANDLERS`` so a new dispatch entry can't be
# added without a matching test (or vice versa). See proxy_api's
# docstring for the JWT-audience reasoning.
# -------------------------------------------------------------------
@pytest.mark.parametrize(
("method", "path", "handler_name"),
[
(method, path, handler_name)
for (method, path), handler_name in sorted(_PROXY_AUTH_LOCAL_HANDLERS.items())
],
)
def test_proxy_auth_endpoint_dispatches_to_local_handler(
self, client, method, path, handler_name
):
"""Every entry in ``_PROXY_AUTH_LOCAL_HANDLERS`` must route to its
local console handler and never reach the upstream proxy. The
lockout class of bug this dispatch was added to fix is exactly
what a regression here would reintroduce silently — covering all
eight branches keeps each path tied to its handler."""
from unittest.mock import AsyncMock, patch
from starlette.responses import JSONResponse
with (
patch(
f"turnstone.console.server.{handler_name}",
new_callable=AsyncMock,
return_value=JSONResponse({"status": "ok"}),
) as local_mock,
patch(
"turnstone.console.server._proxy_post",
new_callable=AsyncMock,
return_value=JSONResponse({"status": "should-not-be-called"}),
) as post_mock,
patch(
"turnstone.console.server._proxy_get",
new_callable=AsyncMock,
return_value=JSONResponse({"status": "should-not-be-called"}),
) as get_mock,
):
resp = client.request(method, f"/node/node-a/v1/api/{path}")
assert resp.status_code == 200
assert local_mock.await_count == 1
assert post_mock.await_count == 0
assert get_mock.await_count == 0
def test_proxy_auth_login_works_without_cookie(self, mock_collector):
"""Without this fix the AuthMiddleware 401s before any handler
runs — the user is locked out of the proxied UI once the cookie
expires. Test bypasses _TEST_AUTH_HEADERS to reproduce."""
from unittest.mock import AsyncMock, patch
from starlette.responses import JSONResponse
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
_load_static()
app = create_app(collector=mock_collector, jwt_secret=_TEST_JWT_SECRET)
unauth_client = TestClient(app, raise_server_exceptions=False)
try:
with patch(
"turnstone.console.server.auth_login",
new_callable=AsyncMock,
return_value=JSONResponse({"status": "ok"}),
) as local_mock:
resp = unauth_client.post(
"/node/node-a/v1/api/auth/login",
json={"username": "x", "password": "y"},
)
# AuthMiddleware must classify the proxied login path as
# public (is_public_path change) AND proxy_api must
# dispatch to the local handler (proxy_api change).
assert resp.status_code == 200, (
f"login locked out: got {resp.status_code}, body={resp.text}"
)
assert local_mock.await_count == 1
finally:
unauth_client.close()
def test_proxy_auth_wrong_method_returns_405_not_forwarded(self, client):
"""A non-canonical method on an auth path (e.g. PUT on auth/login)
must short-circuit with 405 instead of falling through to the
upstream proxy — falling through would forward the request
authenticated as the console's service token (``_proxy_auth_headers``
fallback)."""
from unittest.mock import AsyncMock, patch
from starlette.responses import JSONResponse
with (
patch(
"turnstone.console.server._proxy_post",
new_callable=AsyncMock,
return_value=JSONResponse({"status": "should-not-be-called"}),
) as post_mock,
patch(
"turnstone.console.server._proxy_get",
new_callable=AsyncMock,
return_value=JSONResponse({"status": "should-not-be-called"}),
) as get_mock,
):
# PUT on a POST-only auth path → 405
put_resp = client.put("/node/node-a/v1/api/auth/login")
assert put_resp.status_code == 405
# POST on a GET-only auth path → 405
post_resp = client.post("/node/node-a/v1/api/auth/status")
assert post_resp.status_code == 405
assert post_mock.await_count == 0
assert get_mock.await_count == 0
def test_proxy_non_auth_endpoint_still_forwarded(self, client, mock_collector):
"""Sanity: only auth/* paths intercept. Other API paths still
forward to the upstream node."""
from unittest.mock import AsyncMock, patch
from starlette.responses import JSONResponse
mock_collector.get_node_detail.return_value = {
"node_id": "node-a",
"server_url": "http://a:8080",
"reachable": True,
}
with patch(
"turnstone.console.server._proxy_get",
new_callable=AsyncMock,
return_value=JSONResponse({"ok": True}),
) as proxy_mock:
resp = client.get("/node/node-a/v1/api/workstreams")
assert resp.status_code == 200
assert proxy_mock.await_count == 1
# ---------------------------------------------------------------------------
# Proxy URL rewriting unit tests (no HTTP needed)
+229 -29
View File
@@ -5,11 +5,15 @@ lifting is in ``SessionManager.close_idle`` (covered in
``test_session_manager.py``) and ``bulk_close_stale_orphans`` (covered
in ``test_storage_sqlite.py``). These tests verify the glue:
- the helper runs an initial sweep BEFORE its first sleep (cold-start
- the helper runs an initial sweep BEFORE its first wait (cold-start
cleanup without blocking the lifespan),
- the helper swallows exceptions so a transient DB blip can't kill the
daemon thread,
- the helper exits cleanly when ``stop_event`` is set.
- the helper exits cleanly when ``stop_event`` is set,
- the helper subscribes to ``mgr.subscribe_to_state`` and a state-change
event wakes the next sweep early (event-driven, not polling),
- the helper unsubscribes when the thread exits so the subscriber
doesn't leak past one cleanup-thread lifetime.
The ``stop_event`` parameter is exclusively for tests — production
callers pass ``None`` and the daemon runs for process lifetime.
@@ -17,28 +21,36 @@ callers pass ``None`` and the daemon runs for process lifetime.
from __future__ import annotations
import contextlib
import threading
from unittest.mock import patch
import time
from typing import TYPE_CHECKING
from turnstone.console.server import _coord_idle_cleanup_thread
if TYPE_CHECKING:
from collections.abc import Callable
class _StubMgr:
"""Minimal SessionManager substitute exposing only what the cleanup
thread touches: ``close_idle``, ``subscribe_to_state``,
``unsubscribe_from_state``. Records call ordering for assertions
and lets the test fire state-change events manually via
:meth:`fire_state_change`.
"""
def __init__(
self, *, stop_event: threading.Event, expected_calls: int, raise_after: int = -1
) -> None:
self.calls: list[float] = []
self.sleep_calls_at_each_close: list[int] = []
self._stop_event = stop_event
self._expected = expected_calls
self._raise_after = raise_after
self._sleep_count = 0
self._subscribers: list[Callable[[str, object], None]] = []
self._sub_lock = threading.Lock()
def close_idle(self, timeout_sec: float) -> list[str]:
# Snapshot how many sleeps preceded this close — lets the
# "initial sweep" test verify the first close_idle ran with
# zero preceding sleeps.
self.sleep_calls_at_each_close.append(self._sleep_count)
self.calls.append(timeout_sec)
try:
if 0 <= self._raise_after < len(self.calls):
@@ -50,39 +62,78 @@ class _StubMgr:
self._stop_event.set()
return []
def record_sleep(self, _seconds: float) -> None:
self._sleep_count += 1
def subscribe_to_state(self, callback: Callable[[str, object], None]) -> None:
with self._sub_lock:
self._subscribers.append(callback)
def unsubscribe_from_state(self, callback: Callable[[str, object], None]) -> None:
with self._sub_lock, contextlib.suppress(ValueError):
self._subscribers.remove(callback)
@property
def subscribers_count(self) -> int:
with self._sub_lock:
return len(self._subscribers)
def fire_state_change(self, ws_id: str = "ws-x", state: object = "idle") -> None:
with self._sub_lock:
snapshot = list(self._subscribers)
for cb in snapshot:
cb(ws_id, state)
def _run_until_done(mgr: _StubMgr, stop_event: threading.Event, timeout_sec: float) -> None:
with patch("turnstone.console.server.time.sleep", mgr.record_sleep):
thread = threading.Thread(
target=_coord_idle_cleanup_thread,
args=(mgr, timeout_sec, stop_event),
daemon=True,
)
thread.start()
thread.join(timeout=2.0)
assert not thread.is_alive(), "helper failed to exit on stop_event"
# ``min_sweep_interval=0.0`` disables the production cadence floor
# (default 5 s) so tests can fire many close_idle calls back-to-back
# without waiting real time between them. The floor is exercised
# in its own dedicated test below.
thread = threading.Thread(
target=_coord_idle_cleanup_thread,
args=(mgr, timeout_sec, stop_event),
kwargs={"min_sweep_interval": 0.0},
daemon=True,
)
thread.start()
thread.join(timeout=2.0)
assert not thread.is_alive(), "helper failed to exit on stop_event"
def test_coord_idle_cleanup_runs_initial_sweep_before_sleep() -> None:
"""The first close_idle call must happen BEFORE the first time.sleep
def test_coord_idle_cleanup_runs_initial_sweep_before_wait() -> None:
"""The first close_idle call must happen BEFORE the first wait
otherwise cold-start orphans wait one ``check_every`` interval (~30 min
on default 2h timeout) for the first reap. Crucial because the
lifespan no longer does a synchronous initial sweep."""
lifespan no longer does a synchronous initial sweep.
Verified structurally: a single ``expected_calls=1`` run completes
in well under one ``check_every`` (here 0.04 s timeout → 0.01 s
check_every), so the initial sweep must have happened before any
real wait could have blocked it.
"""
stop_event = threading.Event()
mgr = _StubMgr(stop_event=stop_event, expected_calls=1)
_run_until_done(mgr, stop_event, timeout_sec=120.0)
assert mgr.sleep_calls_at_each_close == [0], "first close_idle should run before any sleep"
started = time.monotonic()
_run_until_done(mgr, stop_event, timeout_sec=0.04)
elapsed = time.monotonic() - started
assert len(mgr.calls) == 1
# check_every = min(300.0, 0.04/4) = 0.01 s. An initial sweep
# gated behind one full wait would have taken ~0.01+ s anyway, so
# the upper bound here is "much less than one check_every plus
# process noise" — the explicit 1.0 s gives generous CI headroom
# while still asserting the test is testing the right thing.
assert elapsed < 1.0
def test_coord_idle_cleanup_calls_close_idle_each_tick() -> None:
"""Heartbeat path: with no state-change events, close_idle fires
each ``check_every`` interval. Test uses a tiny timeout so the
test runs fast — the contract under test is "the loop iterates",
not the production cadence.
"""
stop_event = threading.Event()
mgr = _StubMgr(stop_event=stop_event, expected_calls=3)
_run_until_done(mgr, stop_event, timeout_sec=120.0)
_run_until_done(mgr, stop_event, timeout_sec=0.04)
assert len(mgr.calls) == 3
assert all(t == 120.0 for t in mgr.calls)
assert all(t == 0.04 for t in mgr.calls)
def test_coord_idle_cleanup_survives_close_idle_exceptions() -> None:
@@ -91,7 +142,7 @@ def test_coord_idle_cleanup_survives_close_idle_exceptions() -> None:
blip would silently leak orphans forever."""
stop_event = threading.Event()
mgr = _StubMgr(stop_event=stop_event, expected_calls=4, raise_after=1)
_run_until_done(mgr, stop_event, timeout_sec=120.0)
_run_until_done(mgr, stop_event, timeout_sec=0.04)
# All four calls must have fired despite calls 2-4 raising.
assert len(mgr.calls) == 4
@@ -102,5 +153,154 @@ def test_coord_idle_cleanup_exits_cleanly_on_stop_event() -> None:
daemon-process termination."""
stop_event = threading.Event()
mgr = _StubMgr(stop_event=stop_event, expected_calls=2)
_run_until_done(mgr, stop_event, timeout_sec=120.0)
_run_until_done(mgr, stop_event, timeout_sec=0.04)
assert stop_event.is_set()
def test_state_change_wakes_close_idle_before_heartbeat() -> None:
"""The event-driven path is the whole point of the refactor: a
workstream state-change must wake the cleanup sweep without
waiting one ``check_every`` interval. Tested with a long
timeout_sec so the heartbeat would NOT have fired in the test
window — the close_idle call past the initial sweep must come
from a state-change wake.
"""
stop_event = threading.Event()
mgr = _StubMgr(stop_event=stop_event, expected_calls=2)
# check_every = min(300.0, 120.0/4) = 30 s — well outside the test
# window. Any close_idle call past the initial sweep must come
# from a fire_state_change-driven wake-up.
thread = threading.Thread(
target=_coord_idle_cleanup_thread,
args=(mgr, 120.0, stop_event),
kwargs={"min_sweep_interval": 0.0},
daemon=True,
)
thread.start()
# Wait for the initial sweep to complete AND the thread to enter
# its first ``tick_now.wait`` (signalled here by the subscriber
# being registered + calls advancing to 1).
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline:
if mgr.subscribers_count == 1 and len(mgr.calls) >= 1:
break
time.sleep(0.01)
assert mgr.subscribers_count == 1, "thread didn't subscribe to state"
assert len(mgr.calls) == 1, "initial sweep didn't fire"
# One state-change fire wakes the first ``wait`` → close_idle runs
# again → stop_event is set (expected_calls=2) → thread exits.
mgr.fire_state_change()
thread.join(timeout=2.0)
assert not thread.is_alive(), "thread didn't exit after state-change-driven sweep"
# 2 = initial + state-change-driven. If the state change weren't
# being honoured, close_idle would have stalled on the 30 s wait
# and the thread.join would have timed out.
assert len(mgr.calls) == 2
def test_subscriber_unregisters_when_thread_exits() -> None:
"""The cleanup thread's state-change subscriber must be removed
when the thread exits — otherwise long-running processes that
restart their cleanup threads (admin model-CRUD path, tests) leak
subscribers and every state change fires N stale callbacks.
"""
stop_event = threading.Event()
mgr = _StubMgr(stop_event=stop_event, expected_calls=1)
_run_until_done(mgr, stop_event, timeout_sec=0.04)
assert mgr.subscribers_count == 0, "subscriber leaked past thread exit"
def test_state_change_during_close_idle_triggers_followup_sweep() -> None:
"""A state-change fired during the initial sweep (e.g. close_idle's
own ``close()`` calls firing subscribers) must wake the next
``tick_now.wait`` rather than being lost to the clear-before-sweep
ordering. The clear runs INSIDE the loop just before close_idle,
so a fire during the initial sweep — which precedes the loop —
arrives at an already-set event that the first wait sees set and
returns on immediately.
"""
stop_event = threading.Event()
mgr = _StubMgr(stop_event=stop_event, expected_calls=2)
real_close_idle = mgr.close_idle
# One-shot fire during the initial sweep, mirroring what
# close_idle's own close() calls do in production (set_state →
# state-change subscribers).
fired = [False]
def _instrumented_close_idle(timeout_sec: float) -> list[str]:
result = real_close_idle(timeout_sec)
if not fired[0]:
fired[0] = True
mgr.fire_state_change()
return result
mgr.close_idle = _instrumented_close_idle # type: ignore[method-assign]
thread = threading.Thread(
target=_coord_idle_cleanup_thread,
args=(mgr, 120.0, stop_event),
kwargs={"min_sweep_interval": 0.0},
daemon=True,
)
thread.start()
thread.join(timeout=2.0)
assert not thread.is_alive(), "thread blocked on the next wait — mid-sweep wake was lost"
# 2 = initial sweep + state-change-driven follow-up. Without the
# event surviving the clear-before-sweep ordering, the thread
# would have blocked on the 30 s ``wait`` and the test would have
# timed out at thread.join.
assert len(mgr.calls) == 2
def test_min_sweep_interval_floors_close_idle_cadence_under_sustained_wakes() -> None:
"""Cadence floor: even when state-change events keep firing
``tick_now.set()``, ``close_idle`` must not run more often than
``min_sweep_interval`` — otherwise the loop tight-spins close_idle
at the rate of its own DB latency, doing 600-1500x more DB work
than the pre-refactor fixed-30 s cadence.
Wires a state-change subscriber that fires another state change
from inside close_idle, so the bus would tick forever if not
floored. Asserts the elapsed-between-sweeps is at least
``min_sweep_interval`` modulo small wall-clock noise.
"""
stop_event = threading.Event()
mgr = _StubMgr(stop_event=stop_event, expected_calls=3)
real_close_idle = mgr.close_idle
sweep_times: list[float] = []
def _instrumented_close_idle(timeout_sec: float) -> list[str]:
sweep_times.append(time.monotonic())
result = real_close_idle(timeout_sec)
# Always fire another state-change to simulate sustained
# activity (each turn fires thinking/running/attention/idle).
# If the floor were absent, the next wake would race the next
# close_idle immediately and ``sweep_times`` deltas would be
# bounded by close_idle latency (microseconds), not the floor.
mgr.fire_state_change()
return result
mgr.close_idle = _instrumented_close_idle # type: ignore[method-assign]
# 0.15 s floor keeps the test fast (~0.3 s total) while still
# representing a meaningful gap relative to close_idle's
# near-zero stub latency.
thread = threading.Thread(
target=_coord_idle_cleanup_thread,
args=(mgr, 120.0, stop_event),
kwargs={"min_sweep_interval": 0.15},
daemon=True,
)
thread.start()
thread.join(timeout=3.0)
assert not thread.is_alive(), "thread didn't exit"
assert len(sweep_times) >= 2, "fewer than two sweeps fired"
# Gap between sweep 1 (post-initial) and sweep 2 must respect
# the floor. Initial sweep at sweep_times[0] is unfloored
# (no prior sweep to compare against), so the meaningful
# assertion is on sweep_times[1] - sweep_times[0].
gap = sweep_times[1] - sweep_times[0]
assert gap >= 0.12, f"floor breached: gap {gap:.3f}s < min_sweep_interval 0.15s"
+79
View File
@@ -716,3 +716,82 @@ class TestCoordinatorAdapterDispatchChildEvent:
},
)
assert recorder.enqueued == []
def test_dispatch_notifies_child_event_bus_on_state_event(self) -> None:
"""Every translated state-class event must call
``ChildEventBus.notify(ws_id)`` so a registered
``wait_for_workstream`` waiter wakes promptly. Notify fires
AFTER the UI enqueue so the SSE fan-out keeps priority — the
order assertion here is structural (one notify call, matching
ws_id) since the bus side-effect lookup is what guards against
regressions, not the relative event ordering.
"""
adapter, _, _ = self._setup()
adapter._registry.merge_children("coord-a", ["child-a1"])
bus = adapter.child_event_bus
event = bus.register_waiter(["child-a1"])
adapter._dispatch_child_event(
{
"type": "cluster_state",
"ws_id": "child-a1",
"state": "idle",
}
)
assert event.is_set(), "bus notify did not fire on cluster_state dispatch"
def test_dispatch_notifies_for_all_state_class_event_types(self) -> None:
"""The dispatch sink translates six event types into the
``child_ws_*`` SSE shape; all six must also fire the bus so
a wait on any of them wakes. ``ws_created`` is intentionally
NOT in this set — waiters register against ws_ids they already
know exist (the wait tool takes a pre-known list)."""
for etype, extra in [
("cluster_state", {"state": "running"}),
("ws_closed", {"reason": "evicted"}),
("ws_rename", {"name": "renamed"}),
("intent_verdict", {"verdict": {"call_id": "c1"}}),
("approval_resolved", {"approved": True}),
("approve_request", {"detail": {}}),
]:
adapter, _, _ = self._setup()
adapter._registry.merge_children("coord-a", ["child-a1"])
bus = adapter.child_event_bus
event = bus.register_waiter(["child-a1"])
adapter._dispatch_child_event(
{"type": etype, "ws_id": "child-a1", **extra},
)
assert event.is_set(), f"bus notify did not fire on {etype} dispatch"
def test_dispatch_does_not_notify_for_unrelated_ws_id(self) -> None:
"""Bus is keyed by ws_id — a dispatch for ws X must not wake a
waiter registered against ws Y, or every state change anywhere
in the system would shake every concurrent wait."""
adapter, _, _ = self._setup()
adapter._registry.merge_children("coord-a", ["child-a1"])
bus = adapter.child_event_bus
event = bus.register_waiter(["child-other"])
adapter._dispatch_child_event(
{
"type": "cluster_state",
"ws_id": "child-a1",
"state": "idle",
}
)
assert not event.is_set(), "bus notify spuriously fired on unrelated ws_id"
def test_dispatch_does_not_notify_for_unknown_child(self) -> None:
"""Events whose ws_id isn't in any coord's registry are dropped
BEFORE the bus notify (early return at ``coord_id is None``).
Notify only fires for events the dispatch sink fully translated,
keeping the bus side-effect aligned with the UI enqueue."""
adapter, _, _ = self._setup()
bus = adapter.child_event_bus
event = bus.register_waiter(["ws-orphan"])
adapter._dispatch_child_event(
{
"type": "cluster_state",
"ws_id": "ws-orphan",
"state": "idle",
}
)
assert not event.is_set(), "bus notify fired for ws_id the dispatch dropped"
+649 -9
View File
@@ -9,6 +9,7 @@ storage-call path.
from __future__ import annotations
import json
import time
from typing import TYPE_CHECKING, Any
import httpx
@@ -20,6 +21,7 @@ from turnstone.console.coordinator_client import (
CoordinatorTokenManager,
)
from turnstone.core.auth import JWT_AUD_CONSOLE, validate_jwt
from turnstone.core.child_event_bus import ChildEventBus
from turnstone.core.storage._sqlite import SQLiteBackend
if TYPE_CHECKING:
@@ -145,6 +147,7 @@ def _mock_client(
coord_ws_id="coord-1",
user_id="user-1",
http_client=http,
child_event_bus=ChildEventBus(),
)
return client, captured
@@ -470,6 +473,7 @@ def _make_read_client(storage: SQLiteBackend) -> CoordinatorClient:
coord_ws_id="coord-1",
user_id="user-1",
http_client=http,
child_event_bus=ChildEventBus(),
)
@@ -663,6 +667,7 @@ def _make_client_with_cluster_response(
coord_ws_id="coord-1",
user_id="user-1",
http_client=http,
child_event_bus=ChildEventBus(),
)
@@ -1223,6 +1228,49 @@ def test_list_skills_hides_interactive_only_skills(tmp_path):
assert skill["kind"] in {"coordinator", "any"}
def test_list_skills_omits_allowed_tools_when_empty(tmp_path):
"""``allowed_tools`` is the auto-approve allowlist (tools exempt
from the operator approval gate), NOT the set of tools the skill
can use. An empty list reads as "no tool access" to a model
that doesn't know the semantics — real misdiagnosis source: a
code-review skill with no auto-approve allowlist looked like it
had been spawned with zero tools. Dropping the key when empty
removes the ambiguity at the source; absence of the field carries
the unambiguous meaning "no tool is pre-approved for this skill"
while a tool list reads as "these specific tools bypass the prompt".
"""
st = SQLiteBackend(str(tmp_path / "skills_empty.db"))
st.create_prompt_template(
template_id="s-empty",
name="empty-skill",
category="ops",
content="",
variables="[]",
is_default=False,
org_id="",
created_by="test",
tags="[]",
allowed_tools="[]",
)
st.create_prompt_template(
template_id="s-nonempty",
name="nonempty-skill",
category="ops",
content="",
variables="[]",
is_default=False,
org_id="",
created_by="test",
tags="[]",
allowed_tools='["read_file"]',
)
client = _make_read_client(st)
result = client.list_skills()
by_name = {s["name"]: s for s in result["skills"]}
assert "allowed_tools" not in by_name["empty-skill"]
assert by_name["nonempty-skill"]["allowed_tools"] == ["read_file"]
def test_list_skills_projects_allowed_tools_capped_with_sentinel(tmp_path):
"""Each row carries the skill's allowed_tools (capped at the projection
cap with a +N more sentinel) so coordinators can pick a skill without
@@ -1443,6 +1491,23 @@ def test_wait_for_workstream_denies_foreign_ws_id(populated_storage):
assert result["elapsed"] < 1.0
def test_wait_for_workstream_denies_cross_tenant_child(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
``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").
"""
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
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
@@ -1531,10 +1596,22 @@ def test_wait_for_workstream_dedupes_ws_ids(populated_storage):
assert list(result["results"].keys()) == ["child-a"]
def test_wait_for_workstream_uses_batched_storage_calls(populated_storage, monkeypatch):
"""Per-tick polling must issue batched storage calls — at the
documented cap (32 ws_ids over a 600s wait) the naive per-id
shape produced ~38k row reads. Guard against regression."""
def test_wait_for_workstream_never_falls_back_to_per_id_storage_calls(
populated_storage, monkeypatch
):
"""All storage reads issued by ``wait_for_workstream`` must go
through the batched paths. At the documented cap (32 ws_ids over
a 600 s wait) the naive per-id shape produced ~38k row reads, so
a regression to per-id is the meaningful failure mode this test
guards against.
The primary safety net is the ``pytest.fail`` mock on the per-id
``get_workstream`` / ``sum_workstream_tokens`` paths any call
there blows up loudly with the regression message. The
additional ``batch_calls`` / ``sum_calls`` assertions cover the
subtler regression where the call IS batched but only covers a
subset of ws_ids (e.g. one ws_id per call in a loop).
"""
client = _make_read_client(populated_storage)
batch_calls: list[list[str]] = []
sum_calls: list[list[str]] = []
@@ -1565,11 +1642,16 @@ def test_wait_for_workstream_uses_batched_storage_calls(populated_storage, monke
result = client.wait_for_workstream(["child-a", "child-b"], timeout=5, mode="any")
assert result["complete"] is True
# One tick is enough since child-a is already idle (terminal).
assert len(batch_calls) == 1
assert len(sum_calls) == 1
assert set(batch_calls[0]) == {"child-a", "child-b"}
assert set(sum_calls[0]) == {"child-a", "child-b"}
# Every batched call carried the full ws_id set. The exact count
# (currently 2: one pre-loop ownership filter + one snapshot tick)
# is incidental; if either gains another batched read it stays
# batched, which is the property under test.
assert batch_calls, "no batched get_workstreams_batch call observed"
assert sum_calls, "no batched sum_workstream_tokens_batch call observed"
first_batch = set(batch_calls[0])
first_sum = set(sum_calls[0])
assert first_batch == {"child-a", "child-b"}
assert first_sum == {"child-a", "child-b"}
def test_wait_for_workstream_handles_non_string_mode(populated_storage):
@@ -1581,6 +1663,205 @@ def test_wait_for_workstream_handles_non_string_mode(populated_storage):
assert "invalid mode" in result["error"]
# ---------------------------------------------------------------------------
# wait_for_workstream — event-driven (ChildEventBus wired in)
# ---------------------------------------------------------------------------
#
# When the coord adapter wires its ``child_event_bus`` into the client,
# the wait loop blocks on a per-call ``threading.Event`` keyed by ws_id
# and only re-snapshots storage on state-change wakes or the heartbeat
# cap. The legacy ``time.sleep`` poll path remains intact for tests
# that don't wire the bus (above), so this section adds focused
# coverage of the bus-driven behaviour without re-running the full
# matrix of mode / since / cross-tenant cases.
def _make_read_client_with_bus(storage, bus) -> CoordinatorClient:
"""Like ``_make_read_client`` but wires a real ``ChildEventBus``.
Caller owns the bus so the test can call ``bus.notify(ws_id)`` to
simulate the dispatch-sink wake-up.
"""
transport = httpx.MockTransport(lambda r: httpx.Response(200))
http = httpx.Client(transport=transport)
return CoordinatorClient(
console_base_url="http://x",
storage=storage,
token_factory=lambda: "t",
coord_ws_id="coord-1",
user_id="user-1",
http_client=http,
child_event_bus=bus,
)
def test_wait_with_bus_returns_immediately_when_already_terminal(populated_storage):
"""Subscribe-after-terminal race: the wait registers its waiter
BEFORE the first snapshot, then re-snapshots an already-terminal
child must return at once without spinning the heartbeat cap.
"""
from turnstone.core.child_event_bus import ChildEventBus
bus = ChildEventBus()
client = _make_read_client_with_bus(populated_storage, bus)
result = client.wait_for_workstream(["child-a"], timeout=5, mode="any")
assert result["complete"] is True
assert result["results"]["child-a"]["state"] == "idle"
assert result["elapsed"] < 1.0
# Waiter must be unregistered on exit so a long-lived bus doesn't
# accumulate dead keys across many waits.
assert "child-a" not in bus._waiters
def test_wait_with_bus_wakes_on_notify(populated_storage):
"""The core property of the refactor: a state-change ``notify``
must wake the wait promptly well under the legacy 0.5 s poll
cadence AND the 2 s heartbeat cap. Test fires a state update
+ notify after a short delay and asserts the wait returns quickly.
"""
import threading as _t
from turnstone.core.child_event_bus import ChildEventBus
bus = ChildEventBus()
client = _make_read_client_with_bus(populated_storage, bus)
# child-b starts running; flip to idle + notify after the wait
# blocks. 100 ms is enough that the wait is parked in event.wait()
# but short enough that the test runs fast.
timer = _t.Timer(
0.1,
lambda: (
populated_storage.update_workstream_state("child-b", "idle"),
bus.notify("child-b"),
),
)
timer.start()
start = time.monotonic()
result = client.wait_for_workstream(["child-b"], timeout=5.0, mode="any")
elapsed = time.monotonic() - start
assert result["complete"] is True
assert result["results"]["child-b"]["state"] == "idle"
# Bus-driven wake should fire well under 1 s; legacy poll would
# take ~0.5 s but bus-driven should be ~0.1 s (the timer delay)
# plus a few ms. Generous 0.6 s budget for CI noise.
assert elapsed < 0.6, f"wake-up too slow: {elapsed}s"
def test_wait_with_bus_unrelated_notify_does_not_wake(populated_storage):
"""A notify on a ws_id the wait isn't watching must NOT wake it —
otherwise every state change anywhere on the system would shake
every concurrent wait into a redundant storage snapshot.
"""
from turnstone.core.child_event_bus import ChildEventBus
bus = ChildEventBus()
client = _make_read_client_with_bus(populated_storage, bus)
# child-b is running indefinitely; mode='all' will time out unless
# a relevant notify fires. Fire only unrelated notifies — wait
# should still hit the full timeout.
import threading as _t
def _fire_unrelated() -> None:
for _ in range(5):
bus.notify("ws-unrelated-1")
bus.notify("ws-unrelated-2")
time.sleep(0.05)
t = _t.Thread(target=_fire_unrelated, daemon=True)
t.start()
start = time.monotonic()
result = client.wait_for_workstream(["child-b"], timeout=0.5, mode="all")
elapsed = time.monotonic() - start
assert result["complete"] is False, "unrelated notify falsely satisfied wait"
# Wait should burn its full timeout (give or take heartbeat
# granularity). The bus path doesn't have a 0.5 s poll, so the
# bound is "approximately timeout".
assert elapsed >= 0.5
t.join(timeout=1.0)
def test_wait_with_bus_heartbeat_still_progresses_without_notify(populated_storage):
"""Without any notify, the wait must still progress through ticks
via the heartbeat cap so ``progress_callback`` keeps firing for
the sidebar UI. Verified by counting callback firings over an
interval longer than the heartbeat.
"""
from turnstone.core.child_event_bus import ChildEventBus
bus = ChildEventBus()
client = _make_read_client_with_bus(populated_storage, bus)
# Shrink the heartbeat for test speed via the ClassVar seam —
# instance attribute shadows the class-level default. Production
# stays at 2.0 s; the test exercises the heartbeat-fires-without-
# notify property in well under 1 s.
client._WAIT_HEARTBEAT_INTERVAL = 0.1 # type: ignore[misc]
snapshots: list[dict[str, dict[str, object]]] = []
def _cb(snap: dict[str, dict[str, object]], _elapsed: float) -> None:
snapshots.append(snap)
# child-b is running indefinitely; wait will time out at 0.4 s.
# With heartbeat = 0.1 s, we expect ~3-5 callback firings
# (initial tick + ~3-4 heartbeats). Loose lower bound to avoid
# CI flakiness.
start = time.monotonic()
result = client.wait_for_workstream(["child-b"], timeout=0.4, mode="all", progress_callback=_cb)
elapsed = time.monotonic() - start
assert result["complete"] is False
assert elapsed >= 0.4
# At least 2 callback firings: the initial snapshot plus at least
# one heartbeat-driven re-tick. Tight upper bound would be
# ~ceil(0.4/0.1) + 1 = 5 firings.
assert len(snapshots) >= 2, f"heartbeat didn't fire: {len(snapshots)} snapshots"
def test_wait_with_bus_unregisters_waiter_on_exit(populated_storage):
"""Both the success path and the timeout path must unregister the
waiter otherwise a long-lived bus accumulates dead
``threading.Event`` instances forever.
"""
from turnstone.core.child_event_bus import ChildEventBus
bus = ChildEventBus()
client = _make_read_client_with_bus(populated_storage, bus)
# Success path (already-terminal child).
client.wait_for_workstream(["child-a"], timeout=5, mode="any")
assert bus._waiters == {}, "success path leaked waiter"
# Timeout path (running child, mode='all' that times out).
client.wait_for_workstream(["child-a", "child-b"], timeout=0.3, mode="all")
assert bus._waiters == {}, "timeout path leaked waiter"
def test_wait_with_bus_multi_waiter_independence(populated_storage):
"""Two concurrent waits on the same ws_id must be independent —
one wait completing must not affect the other's wake-up state.
Smoke-tests the multi-Event-per-bucket bus behaviour against the
real wait-loop.
"""
import threading as _t
from turnstone.core.child_event_bus import ChildEventBus
bus = ChildEventBus()
client = _make_read_client_with_bus(populated_storage, bus)
results: dict[str, dict[str, object]] = {}
def _do_wait(label: str) -> None:
results[label] = client.wait_for_workstream(["child-a"], timeout=5, mode="any")
threads = [_t.Thread(target=_do_wait, args=(f"t{i}",), daemon=True) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5.0)
for label in ("t0", "t1", "t2"):
assert results[label]["complete"] is True
assert results[label]["results"]["child-a"]["state"] == "idle"
# All waiters must be unregistered after exit.
assert bus._waiters == {}
# ---------------------------------------------------------------------------
# wait_for_workstream — last-message bundling
# ---------------------------------------------------------------------------
@@ -2378,3 +2659,362 @@ def test_cleanup_dead_task_child_refs_storage_batch_failure_swallows(populated_s
populated_storage.get_workstreams_batch = _boom # type: ignore[method-assign]
assert client.cleanup_dead_task_child_refs("coord-1") == 0
# ---------------------------------------------------------------------------
# inspect_workstream — three-tier output compression
# ---------------------------------------------------------------------------
#
# A coord doing a fan-out wave against tool-heavy children would
# otherwise blow the context budget on raw output alone. Mirrors the
# search tool's Tier-1/Tier-2/Tier-3 ladder.
def _make_inspect_result(
*, ws_id: str = "ws-test", state: str = "running", n_messages: int = 5
) -> dict[str, Any]:
"""Build an inspect-result dict shaped like ``coordinator_client.inspect()``.
Production output keys (``ws_id``, ``skill_id``) mirror the storage
row that ``inspect()`` spreads from ``get_workstream``. Tests that
synthesize an inspect result must match these keys otherwise a
formatter that looks at the production keys silently emits null
values against a fixture that uses different ones (real bug-1
regression source: skeleton tier read ``skill`` from a fixture
that wrote ``skill`` while production wrote ``skill_id``).
"""
return {
"ws_id": ws_id,
"state": state,
"title": "test workstream",
"skill_id": "researcher",
"messages": [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i} content"}
for i in range(n_messages)
],
"verdicts": [],
}
def test_format_inspect_tiered_full_fits_returns_full_tier():
"""Small payloads pass through with `_tier='full'` — no compression."""
from turnstone.console.coordinator_client import _format_inspect_tiered
result = _make_inspect_result(n_messages=3)
out = _format_inspect_tiered(result)
parsed = json.loads(out)
assert parsed["_tier"] == "full"
# Every message verbatim.
assert len(parsed["messages"]) == 3
assert parsed["messages"][0]["content"] == "msg 0 content"
def test_format_inspect_tiered_compact_when_full_exceeds_budget():
"""Large messages trigger the compact tier — head/tail-snipped
content with the rest of the row intact."""
from turnstone.console.coordinator_client import (
_INSPECT_MSG_CONTENT_HEAD,
_INSPECT_MSG_CONTENT_TAIL,
_INSPECT_OUTPUT_BUDGET,
_format_inspect_tiered,
)
# Each message ~5KB; with 20 messages, full tier blows the 32KB budget.
fat = "X" * 5000
result = {
"id": "ws-fat",
"state": "running",
"messages": [{"role": "assistant", "content": fat} for _ in range(20)],
"verdicts": [],
}
out = _format_inspect_tiered(result)
parsed = json.loads(out)
assert parsed["_tier"] == "compact"
# Every message preserved (compact keeps the count, just snips content).
assert len(parsed["messages"]) == 20
# Head/tail snip kicked in.
msg_content = parsed["messages"][0]["content"]
assert msg_content.startswith("X" * _INSPECT_MSG_CONTENT_HEAD)
assert msg_content.endswith("X" * _INSPECT_MSG_CONTENT_TAIL)
assert "chars elided" in msg_content
# Budget invariant — the load-bearing contract of the formatter.
# Without this assertion, a future change to ``_tier_note`` or
# ``_compact_message`` could push the output over budget and the
# ``_truncate_output`` head+tail safety net would silently mask
# the regression, re-introducing the middle-message-drop pathology.
assert len(out) <= _INSPECT_OUTPUT_BUDGET
def test_format_inspect_tiered_compact_when_content_below_snip_threshold():
"""When per-message content is below the snip threshold but the
message COUNT alone overflows the budget, compact tier must still
stay within budget by trimming the message list (head + tail of
messages) rather than degrading straight to skeleton. Bug-3
regression cover: with 400 × 100-char messages, the original
formatter fell through to skeleton because adding ``_tier_note``
to an un-snipped tier-2 produced output strictly larger than
tier-1 (both over budget). The fix preserves messages from both
ends of the list and inserts an ``_omitted`` sentinel."""
from turnstone.console.coordinator_client import (
_INSPECT_OUTPUT_BUDGET,
_format_inspect_tiered,
)
# 400 × ~100 chars → Tier-1 ~53 KB (over budget), per-message
# content under the 964-char snip threshold so content-snipping
# saves nothing. Without the list-trim rung the formatter would
# fall to skeleton and drop all 400 messages.
smallish = "S" * 100
result = {
"ws_id": "ws-many-small",
"state": "running",
"messages": [
{"role": "assistant" if i % 2 == 0 else "user", "content": smallish} for i in range(400)
],
"verdicts": [],
}
out = _format_inspect_tiered(result)
parsed = json.loads(out)
# Should NOT fall through to skeleton — message-list trim preserves
# head + tail of the conversation.
assert parsed["_tier"] == "compact"
assert "messages" in parsed
# Some messages must survive; the trim shape is head + tail with an
# ``_omitted`` sentinel between them.
assert len(parsed["messages"]) > 0
assert len(parsed["messages"]) < 400
# Budget invariant.
assert len(out) <= _INSPECT_OUTPUT_BUDGET
def test_format_inspect_tiered_skeleton_when_compact_also_exceeds_budget():
"""Tier 3 fallback: counts + last assistant preview only. Trigger by
flooding with messages whose content is a multi-block list the
snipper correctly leaves non-string content unchanged (mirrors
Anthropic/OpenAI multi-block content shape), so even after the
(5, 10) message-list trim the surviving 15 messages don't fit in
the 32 KB budget."""
from turnstone.console.coordinator_client import (
_INSPECT_OUTPUT_BUDGET,
_format_inspect_tiered,
)
# 50 messages × multi-block content (~30 KB each — list-shape
# content bypasses the head/tail string snipper because lists
# aren't strings). Even (5, 10) trim leaves 15 × 30 KB which
# blows the 32 KB budget — forces skeleton.
fat_block = {"type": "text", "text": "Y" * 3000}
result = {
"ws_id": "ws-flood",
"state": "running",
"title": "flood",
"skill_id": "researcher",
"messages": [
{
"role": "assistant" if i % 2 == 0 else "user",
"content": [fat_block] * 10,
}
for i in range(50)
],
"verdicts": [],
}
out = _format_inspect_tiered(result)
parsed = json.loads(out)
assert parsed["_tier"] == "skeleton"
assert parsed["message_count"] == 50
# Role distribution surfaces — the "what shape of activity" signal.
assert parsed["roles"]["assistant"] == 25
assert parsed["roles"]["user"] == 25
# No `messages` field at skeleton tier — only the aggregate signal.
assert "messages" not in parsed
# Budget invariant.
assert len(out) <= _INSPECT_OUTPUT_BUDGET
def test_format_inspect_tiered_skeleton_keeps_terminal_state_fields():
"""``close_reason`` / ``last_error`` survive the skeleton fall — they're
small, load-bearing, and the operator needs them to understand WHY
a terminal child landed in its state."""
from turnstone.console.coordinator_client import (
_INSPECT_OUTPUT_BUDGET,
_format_inspect_tiered,
)
# Same flood pattern as the bare-skeleton test (multi-block content
# bypasses the string snipper) — paired with terminal-state fields
# that must survive the skeleton fall.
fat_block = {"type": "text", "text": "Z" * 3000}
result = {
"ws_id": "ws-closed",
"state": "closed",
"title": "done",
"skill_id": "researcher",
"messages": [{"role": "user", "content": [fat_block] * 10} for _ in range(50)],
"verdicts": [],
"close_reason": "task complete: report attached",
"live": None, # filtered by truthy check
}
out = _format_inspect_tiered(result)
parsed = json.loads(out)
assert parsed["_tier"] == "skeleton"
assert parsed["close_reason"] == "task complete: report attached"
# Falsy ``live`` doesn't bleed through.
assert "live" not in parsed
assert len(out) <= _INSPECT_OUTPUT_BUDGET
def test_format_inspect_tiered_error_shapes_bypass_tiering():
"""Cross-tenant / not-found responses keep their original shape — they
carry no messages, are already tiny, and changing them would break
callers that key on the ``error`` field."""
from turnstone.console.coordinator_client import _format_inspect_tiered
result = {"error": "workstream not found", "ws_id": "ws-foreign"}
out = _format_inspect_tiered(result)
parsed = json.loads(out)
assert parsed == {"error": "workstream not found", "ws_id": "ws-foreign"}
# No `_tier` annotation — error shapes are self-describing.
assert "_tier" not in parsed
def test_format_inspect_tiered_compact_preserves_tool_call_linkage():
"""Compact tier keeps ``tool_name`` / ``tool_call_id`` / ``name`` so a
model reading the snipped trace can still pair a tool call to its
response the linkage is load-bearing for "what happened" signal."""
from turnstone.console.coordinator_client import _format_inspect_tiered
fat = "Q" * 5000
result = {
"ws_id": "ws-tools",
"state": "running",
"messages": [
{
"role": "assistant",
"content": fat,
"tool_name": "bash",
"tool_call_id": "call-1",
}
for _ in range(20)
],
"verdicts": [],
}
out = _format_inspect_tiered(result)
parsed = json.loads(out)
assert parsed["_tier"] == "compact"
first = parsed["messages"][0]
assert first["tool_name"] == "bash"
assert first["tool_call_id"] == "call-1"
def test_format_inspect_tiered_compact_preserves_assistant_tool_calls():
"""Compact tier must preserve the assistant-side ``tool_calls`` list
(OpenAI shape: ``[{id, type, function: {name, arguments}}]``) so a
model reading the snipped trace can see WHICH tool was called and
pair it with the corresponding result row via ``id`` ``tool_call_id``.
Bug-2 regression cover: the pre-fix compactor stripped ``tool_calls``,
leaving the audit reader with a tool-result orphan against an
invisible call.
``function.arguments`` strings are snipped head/tail (analogous to
content) because they can be multi-KB JSON; ``id`` and
``function.name`` are preserved verbatim they're the linkage."""
from turnstone.console.coordinator_client import (
_INSPECT_TOOL_ARG_HEAD,
_INSPECT_TOOL_ARG_TAIL,
_format_inspect_tiered,
)
fat_content = "C" * 5000 # forces compact tier
fat_args = "A" * 5000 # forces argument snipping
tool_calls = [
{
"id": "call-abc-123",
"type": "function",
"function": {"name": "bash", "arguments": fat_args},
},
{
"id": "call-def-456",
"type": "function",
"function": {"name": "read_file", "arguments": fat_args},
},
]
result = {
"ws_id": "ws-tool-calls",
"state": "running",
"messages": [
{"role": "assistant", "content": fat_content, "tool_calls": tool_calls}
for _ in range(20)
],
"verdicts": [],
}
out = _format_inspect_tiered(result)
parsed = json.loads(out)
assert parsed["_tier"] == "compact"
first = parsed["messages"][0]
# tool_calls survives compaction.
assert "tool_calls" in first
assert len(first["tool_calls"]) == 2
# Linkage fields verbatim.
assert first["tool_calls"][0]["id"] == "call-abc-123"
assert first["tool_calls"][0]["function"]["name"] == "bash"
assert first["tool_calls"][1]["id"] == "call-def-456"
assert first["tool_calls"][1]["function"]["name"] == "read_file"
# arguments snipped head/tail — both prefix and suffix preserved.
snipped_args = first["tool_calls"][0]["function"]["arguments"]
assert snipped_args.startswith("A" * _INSPECT_TOOL_ARG_HEAD)
assert snipped_args.endswith("A" * _INSPECT_TOOL_ARG_TAIL)
assert "chars elided" in snipped_args
def test_format_inspect_tiered_compact_passes_small_messages_through_unsnipped():
"""Messages under the snip threshold pass through verbatim at compact
tier snipping a 100-byte message costs more bytes (the elision
marker) than it saves."""
from turnstone.console.coordinator_client import _format_inspect_tiered
# Mix: a few large messages force compact tier; small messages must
# not be snipped.
big = "B" * 5000
small = "S" * 50
result = {
"id": "ws-mixed",
"state": "running",
"messages": [{"role": "assistant", "content": big} for _ in range(15)]
+ [{"role": "user", "content": small}],
"verdicts": [],
}
out = _format_inspect_tiered(result)
parsed = json.loads(out)
assert parsed["_tier"] == "compact"
# The trailing small message is exact, not snipped.
assert parsed["messages"][-1]["content"] == small
def test_format_inspect_tiered_emits_tier_note_when_compressed():
"""The ``_tier_note`` advisory tells the LLM how to ask for a tighter
or fuller view next time actionable feedback rather than a bare
"we compressed your output" signal."""
from turnstone.console.coordinator_client import _format_inspect_tiered
fat = "F" * 5000
result = {
"id": "ws-noted",
"state": "running",
"messages": [{"role": "assistant", "content": fat} for _ in range(20)],
"verdicts": [],
}
out = _format_inspect_tiered(result)
parsed = json.loads(out)
assert "_tier_note" in parsed
assert "message_limit" in parsed["_tier_note"]
def test_format_inspect_tiered_full_tier_omits_tier_note():
"""When the full tier fits, no note is emitted — the absence of a
note is the signal that nothing was compressed."""
from turnstone.console.coordinator_client import _format_inspect_tiered
out = _format_inspect_tiered(_make_inspect_result(n_messages=2))
parsed = json.loads(out)
assert parsed["_tier"] == "full"
assert "_tier_note" not in parsed
+3
View File
@@ -40,6 +40,7 @@ from turnstone.console.server import (
_require_coord_mgr,
)
from turnstone.core.auth import AuthResult
from turnstone.core.child_event_bus import ChildEventBus
from turnstone.core.session_manager import SessionManager
from turnstone.core.session_routes import (
SessionEndpointConfig,
@@ -286,6 +287,7 @@ def test_coordinator_client_spawn_close_delete(tmp_path):
coord_ws_id="coord-42",
user_id="user-1",
http_client=http,
child_event_bus=ChildEventBus(),
)
# spawn ---------------------------------------------------------------
@@ -387,6 +389,7 @@ def _read_client(storage: SQLiteBackend) -> CoordinatorClient:
coord_ws_id="coord-root",
user_id="user-1",
http_client=http,
child_event_bus=ChildEventBus(),
)
+33 -4
View File
@@ -215,7 +215,12 @@ def test_spawn_exec_does_not_surface_misleading_status_field(coord_session):
summary tempted callers to write ``if result["status"] == "idle"``
which silently never matched. The summary now omits the field
entirely; lifecycle state lives on the workstream row and is read
via inspect_workstream."""
via inspect_workstream.
Also asserts the return key is ``child_ws_id`` (not ``ws_id``) so
the coordinator LLM doesn't recency-bias toward feeding the spawn
output back into another ``spawn_workstream(ws_id=...)`` call.
"""
sess, coord, _ui = coord_session
coord.spawn.return_value = {
"ws_id": "child-7",
@@ -227,8 +232,9 @@ def test_spawn_exec_does_not_surface_misleading_status_field(coord_session):
_call_id, output = sess._exec_spawn_workstream(item)
body = json.loads(output)
assert "status" not in body
assert "ws_id" not in body
# The substantive fields are still here.
assert body["ws_id"] == "child-7"
assert body["child_ws_id"] == "child-7"
assert body["node_id"] == "node-1"
@@ -248,6 +254,10 @@ def test_spawn_batch_exec_does_not_surface_misleading_status_field(coord_session
body = json.loads(output)
assert "0" in body["results"]
assert "status" not in body["results"]["0"]
# Per-result entries surface ``child_ws_id``, not ``ws_id`` — same
# recency-bias rationale as the spawn_workstream test above.
assert body["results"]["0"]["child_ws_id"] == "c-x"
assert "ws_id" not in body["results"]["0"]
def test_spawn_exec_surfaces_client_error(coord_session):
@@ -260,6 +270,21 @@ def test_spawn_exec_surfaces_client_error(coord_session):
assert ui.tool_results[-1][3] is True # is_error
def test_spawn_exec_treats_missing_ws_id_on_success_path_as_error(coord_session):
"""A malformed upstream response (200-success-shape with no
``ws_id``) used to emit ``{"child_ws_id": null}`` to the LLM,
which then chased a null id through follow-up tools. Now matches
the matching guard in ``_exec_spawn_batch``: surface as a tool
error so the model retries instead of acting on garbage."""
sess, coord, ui = coord_session
# No ``error`` field, but ``ws_id`` is missing — the silent-null path.
coord.spawn.return_value = {"name": "c", "node_id": "node-1", "status": 200}
item = sess._prepare_tool(_tc("spawn_workstream", {"initial_message": "hi"}))
_call_id, output = sess._exec_spawn_workstream(item)
assert "no ws_id" in output
assert ui.tool_results[-1][3] is True # is_error
# ---------------------------------------------------------------------------
# inspect_workstream
# ---------------------------------------------------------------------------
@@ -1410,9 +1435,13 @@ def test_spawn_batch_exec_serialises_spawns_and_returns_results(coord_session):
assert body["denied"] == []
# Keyed by input index (stringified).
assert set(body["results"].keys()) == {"0", "1", "2"}
assert body["results"]["0"]["ws_id"] == "child-0"
assert body["results"]["0"]["child_ws_id"] == "child-0"
assert body["results"]["1"]["node_id"] == "n-1"
assert body["results"]["2"]["ws_id"] == "child-2"
assert body["results"]["2"]["child_ws_id"] == "child-2"
# Confirm we don't leak the old ``ws_id`` key alongside the new
# ``child_ws_id`` — see test_spawn_exec_does_not_surface_misleading_status_field
# for the rationale on the rename.
assert "ws_id" not in body["results"]["0"]
def test_spawn_batch_exec_surfaces_per_item_errors_in_denied(coord_session):
+175
View File
@@ -675,3 +675,178 @@ class TestExtractReasoningForHistory:
extract_reasoning_for_history(messages, surface_persisted_reasoning_flag=True)
assert messages[0]["reasoning"] == "real thought"
assert "_provider_content" not in messages[0]
class TestAttachVllmChatReasoningField:
"""``attach_vllm_chat_reasoning_field`` — Phase 5 surfaces persisted
reasoning as the vLLM-specific ``reasoning`` field on outgoing
assistant messages so vLLM-served reasoning models can thread CoT
across turns.
Drives through the real ``extract_reasoning_text_from_provider_content``
dispatcher no extractor mocks so a regression in either layer
surfaces distinctly. All 3 caller-side gates (provider isinstance,
server_type, operator flag) are exercised by
``test_session_chat_reasoning_replay.py``; this class pins the
helper's projection contract in isolation.
"""
def _assistant_with(self, provider_content: list[dict[str, object]]) -> dict[str, object]:
return {
"role": "assistant",
"content": "Final answer.",
"_provider_content": provider_content,
}
def test_synthetic_reasoning_text_attaches_field(self) -> None:
# Path 3 capture (vLLM --reasoning-parser, llama.cpp
# reasoning_format, Gemini-compat) lands in _provider_content as
# a synthetic reasoning_text block; helper must round-trip it
# back onto the same model on the next turn.
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
msgs = [self._assistant_with([{"type": "reasoning_text", "text": "synth thought"}])]
out = attach_vllm_chat_reasoning_field(msgs)
assert out[0]["reasoning"] == "synth thought"
def test_anthropic_thinking_attaches_field(self) -> None:
# Cross-provider switch: workstream started with Anthropic,
# operator flipped model to a vLLM-served reasoning model.
# Helper extracts the thinking text and discards the signature
# (vLLM doesn't validate signatures).
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
msgs = [
self._assistant_with(
[
{"type": "thinking", "thinking": "claude was here", "signature": "sig"},
{"type": "text", "text": "answer"},
]
)
]
out = attach_vllm_chat_reasoning_field(msgs)
assert out[0]["reasoning"] == "claude was here"
# Signature is dropped at extraction; ``reasoning`` field carries
# plain text only.
assert "sig" not in out[0]["reasoning"]
def test_openai_responses_reasoning_attaches_field(self) -> None:
# Cross-provider switch: workstream started on gpt-5, operator
# flipped to a vLLM-served model. Helper extracts the
# summary[*].text concatenation.
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
msgs = [
self._assistant_with(
[
{
"type": "reasoning",
"id": "r_1",
"summary": [{"type": "summary_text", "text": "responses thought"}],
}
]
)
]
out = attach_vllm_chat_reasoning_field(msgs)
assert out[0]["reasoning"] == "responses thought"
def test_no_provider_content_returns_unchanged(self) -> None:
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
msgs: list[dict[str, object]] = [{"role": "assistant", "content": "plain"}]
out = attach_vllm_chat_reasoning_field(msgs)
assert "reasoning" not in out[0]
# No copy made when there's nothing to attach — same object.
assert out[0] is msgs[0]
def test_empty_provider_content_returns_unchanged(self) -> None:
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
msgs: list[dict[str, object]] = [
{"role": "assistant", "content": "x", "_provider_content": []}
]
out = attach_vllm_chat_reasoning_field(msgs)
assert "reasoning" not in out[0]
assert out[0] is msgs[0]
def test_unknown_block_type_returns_unchanged(self) -> None:
# _provider_content has blocks but none are reasoning-bearing.
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
msgs: list[dict[str, object]] = [
self._assistant_with([{"type": "text", "text": "no reasoning here"}])
]
out = attach_vllm_chat_reasoning_field(msgs)
assert "reasoning" not in out[0]
assert out[0] is msgs[0]
def test_does_not_touch_user_tool_system_messages(self) -> None:
# Only assistant messages get the reasoning field. User / tool /
# system messages pass through by reference.
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
msgs: list[dict[str, object]] = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
{"role": "tool", "tool_call_id": "c1", "content": "out"},
# Even an assistant-shaped non-assistant role (defensive — shouldn't happen)
# must not have provider_content read.
]
out = attach_vllm_chat_reasoning_field(msgs)
assert "reasoning" not in out[0]
assert "reasoning" not in out[1]
assert "reasoning" not in out[2]
# All three return by reference (no allocation when no attach).
for original, returned in zip(msgs, out, strict=True):
assert original is returned
def test_preserves_provider_content_for_downstream_sanitize(self) -> None:
# Helper attaches ``reasoning`` but leaves ``_provider_content``
# in place. Downstream ``sanitize_messages`` (in the provider's
# _prepare_messages) strips the ``_``-prefixed sibling key
# before the wire payload leaves. Helper isn't responsible for
# that strip — composition with sanitize is the contract.
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
original_content = [{"type": "reasoning_text", "text": "kept"}]
msgs = [self._assistant_with(original_content)]
out = attach_vllm_chat_reasoning_field(msgs)
assert out[0]["reasoning"] == "kept"
# Provider content survives on the helper's output dict.
assert out[0]["_provider_content"] == original_content
def test_does_not_mutate_input_messages(self) -> None:
# Pure transform: input list and input dicts are untouched.
# Callers can keep iterating the original list without surprise.
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
original = self._assistant_with([{"type": "reasoning_text", "text": "x"}])
msgs = [original]
attach_vllm_chat_reasoning_field(msgs)
assert "reasoning" not in original
# Original dict untouched even though the function returned a
# modified copy.
def test_mixed_messages_only_attaches_to_assistants_with_reasoning(self) -> None:
# Realistic shape: a workstream with user, assistant-with-reasoning,
# tool, assistant-plain, user. Only the first assistant gets the
# reasoning field; everything else passes through by reference.
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
with_reasoning = self._assistant_with([{"type": "reasoning_text", "text": "thinking"}])
plain_assistant: dict[str, object] = {"role": "assistant", "content": "second"}
msgs: list[dict[str, object]] = [
{"role": "user", "content": "q1"},
with_reasoning,
{"role": "tool", "tool_call_id": "c1", "content": "result"},
plain_assistant,
{"role": "user", "content": "q2"},
]
out = attach_vllm_chat_reasoning_field(msgs)
assert out[0] is msgs[0]
assert out[1]["reasoning"] == "thinking"
assert out[1] is not with_reasoning # new dict for the attached one
assert out[2] is msgs[2]
assert out[3] is plain_assistant
assert "reasoning" not in out[3]
assert out[4] is msgs[4]
+122 -1
View File
@@ -51,7 +51,11 @@ class TestIntentVerdictCRUD:
assert v["tier"] == "heuristic"
assert v["judge_model"] == ""
assert v["latency_ms"] == 2
assert v["user_decision"] == ""
# ``user_decision`` defaults to ``"pending"`` (not the empty
# string) so an audit reader can distinguish in-flight rows
# from pre-convention legacy rows that carry the column's
# server_default of ``""``.
assert v["user_decision"] == "pending"
assert "created" in v
def test_get_nonexistent(self, db):
@@ -114,6 +118,123 @@ class TestIntentVerdictCRUD:
assert ok is False
class TestIntentVerdictUpsert:
"""``upsert_intent_verdict`` — the LLM-tier-aware persistence path.
Backs the heuristic llm_fallback "upgrade in place" pattern.
The async judge's fallback verdicts deliberately reuse the
heuristic ``verdict_id``; a plain INSERT would collide on the
PK and the upgrade would be lost to a silently-swallowed
exception (Postgres logged ``intent_verdicts_pkey`` violations
for every fallback delivery on stable/1.5 smoke tests).
"""
def test_upsert_on_fresh_id_inserts(self, db):
"""No conflict — behaves like a regular INSERT."""
db.upsert_intent_verdict(**_make_verdict_kwargs())
v = db.get_intent_verdict("v_001")
assert v is not None
assert v["tier"] == "heuristic"
assert v["user_decision"] == "pending"
def test_upsert_on_conflict_upgrades_tier_reasoning_judge_model(self, db):
"""On PK conflict: tier, reasoning, judge_model update — every
other field is preserved. Mirrors what the judge emits when
promoting heuristic llm_fallback."""
db.upsert_intent_verdict(
**_make_verdict_kwargs(
tier="heuristic",
reasoning="initial heuristic reasoning",
judge_model="",
)
)
db.upsert_intent_verdict(
**_make_verdict_kwargs(
tier="llm_fallback",
reasoning="initial heuristic reasoning (LLM judge did not return a verdict)",
judge_model="gpt-5-judge",
)
)
v = db.get_intent_verdict("v_001")
assert v is not None
# The three fields that should change.
assert v["tier"] == "llm_fallback"
assert "LLM judge did not return" in v["reasoning"]
assert v["judge_model"] == "gpt-5-judge"
def test_upsert_on_conflict_preserves_user_decision(self, db):
"""LOAD-BEARING: a manually-resolved approval (user_decision=
``"approved"``) or auto-approve-stamped row (user_decision=
``"policy"``/``"blanket"``/etc.) must NOT be clobbered back to
``"pending"`` when the late LLM-fallback verdict lands.
``IntentVerdict.to_dict()`` doesn't project user_decision, so
the upsert's defaulted ``"pending"`` would silently overwrite
the real value if user_decision were in the on-conflict
SET clause."""
db.upsert_intent_verdict(**_make_verdict_kwargs())
ok = db.update_intent_verdict("v_001", user_decision="approved")
assert ok is True
# Simulate the late LLM-fallback delivery — same verdict_id,
# default user_decision (the IntentVerdict.to_dict() shape).
db.upsert_intent_verdict(
**_make_verdict_kwargs(
tier="llm_fallback",
reasoning="extended (LLM judge did not return a verdict)",
judge_model="gpt-5-judge",
)
)
v = db.get_intent_verdict("v_001")
assert v is not None
assert v["user_decision"] == "approved" # NOT clobbered to "pending"
assert v["tier"] == "llm_fallback" # but the upgrade did land
def test_upsert_on_conflict_preserves_identity_and_carried_fields(self, db):
"""Identity columns (ws_id, call_id, func_name, func_args) and
carried-verbatim columns (intent_summary, risk_level,
confidence, recommendation, evidence, latency_ms) are
excluded from the on-conflict SET verify they aren't
changed even when the second upsert passes different values
(defensive against a future judge bug that ships divergent
carried fields)."""
db.upsert_intent_verdict(**_make_verdict_kwargs())
db.upsert_intent_verdict(
**_make_verdict_kwargs(
# Same verdict_id (conflict trigger), divergent everything else.
ws_id="ws-different",
call_id="tc_different",
func_name="bash_v2",
func_args='{"command":"rm -rf /"}',
intent_summary="totally different summary",
risk_level="critical",
confidence=0.0,
recommendation="deny",
evidence='["dangerous"]',
latency_ms=99999,
# The three fields that DO update.
tier="llm_fallback",
reasoning="upgraded reasoning",
judge_model="judge-v2",
)
)
v = db.get_intent_verdict("v_001")
assert v is not None
# All preserved from the first upsert (identity + carried).
assert v["ws_id"] == "ws-abc"
assert v["call_id"] == "tc_001"
assert v["func_name"] == "bash"
assert v["func_args"] == '{"command":"echo hello"}'
assert v["intent_summary"] == "Echo a greeting to stdout"
assert v["risk_level"] == "low"
assert v["confidence"] == 0.85
assert v["recommendation"] == "approve"
assert v["evidence"] == '["The command only prints text."]'
assert v["latency_ms"] == 2
# Only the three updated.
assert v["tier"] == "llm_fallback"
assert v["reasoning"] == "upgraded reasoning"
assert v["judge_model"] == "judge-v2"
# ---------------------------------------------------------------------------
# Bulk insert
# ---------------------------------------------------------------------------
+211
View File
@@ -0,0 +1,211 @@
"""Integration tests for the Phase 9 admin bulk-revoke endpoint.
POST /v1/api/admin/mcp-servers/{name}/bulk-revoke clears every user's
OAuth token for a server (admin-side counterpart to the per-user
DELETE /v1/api/mcp/oauth/connections/{server_name} that shipped in
Phase 8).
Coverage:
- requires ``admin.mcp`` permission (401/403 without).
- 404 when the named server is missing.
- 400 when the server's ``auth_type`` is not ``oauth_user``.
- 200 + ``rows_deleted`` + ``consented_users_before`` on success.
- Audit row written with
``upstream_revoke_outcome="bulk_admin_no_upstream"``.
- Token rows are gone from ``mcp_user_tokens`` post-call.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
from turnstone.console.server import admin_mcp_bulk_revoke
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
class _InjectAdminMcp(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="admin-user",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset({"read", "write", "approve", "admin.mcp"}),
)
return await call_next(request)
class _InjectNoAdminMcp(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="regular-user",
scopes=frozenset({"approve"}),
token_source="jwt",
permissions=frozenset({"read", "write", "approve"}),
)
return await call_next(request)
def _build_app(storage: SQLiteBackend, *, with_admin_mcp: bool = True) -> Starlette:
mw = _InjectAdminMcp if with_admin_mcp else _InjectNoAdminMcp
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route(
"/api/admin/mcp-servers/{name}/bulk-revoke",
admin_mcp_bulk_revoke,
methods=["POST"],
),
],
),
],
middleware=[Middleware(mw)],
)
app.state.auth_storage = storage
return app
@pytest.fixture
def storage(tmp_path: Any) -> SQLiteBackend:
return SQLiteBackend(str(tmp_path / "test.db"))
def _seed_oauth_server(
backend: SQLiteBackend,
*,
name: str = "srv-oauth",
server_id: str = "srv-oauth-id",
) -> None:
backend.create_mcp_server(
server_id=server_id,
name=name,
transport="streamable-http",
url="https://example.com/mcp",
auth_type="oauth_user",
)
def _seed_static_server(
backend: SQLiteBackend,
*,
name: str = "srv-static",
server_id: str = "srv-static-id",
) -> None:
backend.create_mcp_server(
server_id=server_id,
name=name,
transport="streamable-http",
url="https://example.com/mcp",
auth_type="static",
)
def _seed_user_tokens(backend: SQLiteBackend, server_name: str, users: int) -> None:
for i in range(users):
backend.create_mcp_user_token(
f"user-{i}",
server_name,
access_token_ct=b"ct",
refresh_token_ct=None,
expires_at=None,
scopes=None,
as_issuer="https://as.example.com",
audience="https://example.com/mcp",
)
def test_requires_admin_mcp_permission(storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
client = TestClient(_build_app(storage, with_admin_mcp=False))
resp = client.post("/v1/api/admin/mcp-servers/srv-oauth/bulk-revoke")
assert resp.status_code == 403
def test_404_on_missing_server(storage: SQLiteBackend) -> None:
client = TestClient(_build_app(storage))
resp = client.post("/v1/api/admin/mcp-servers/never-existed/bulk-revoke")
assert resp.status_code == 404
assert resp.json() == {"error": "No such server"}
def test_400_on_static_server(storage: SQLiteBackend) -> None:
_seed_static_server(storage)
client = TestClient(_build_app(storage))
resp = client.post("/v1/api/admin/mcp-servers/srv-static/bulk-revoke")
assert resp.status_code == 400
body = resp.json()
assert "oauth_user" in body["error"]
def test_400_on_invalid_server_name(storage: SQLiteBackend) -> None:
# double-underscore is reserved for the prefixed-tool-name encoding.
client = TestClient(_build_app(storage))
resp = client.post("/v1/api/admin/mcp-servers/bad__name/bulk-revoke")
assert resp.status_code == 400
def test_200_on_success_with_no_consented_users(storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
client = TestClient(_build_app(storage))
resp = client.post("/v1/api/admin/mcp-servers/srv-oauth/bulk-revoke")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["rows_deleted"] == 0
assert body["consented_users_before"] == 0
def test_200_clears_all_user_tokens(storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
_seed_user_tokens(storage, "srv-oauth", users=3)
# Token for another server must survive the bulk-revoke.
_seed_oauth_server(storage, name="srv-other", server_id="srv-other-id")
_seed_user_tokens(storage, "srv-other", users=2)
client = TestClient(_build_app(storage))
resp = client.post("/v1/api/admin/mcp-servers/srv-oauth/bulk-revoke")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["rows_deleted"] == 3
assert body["consented_users_before"] == 3
# Target server's tokens are gone; bystander's tokens survive.
assert storage.count_mcp_consented_users_by_server("srv-oauth") == 0
assert storage.count_mcp_consented_users_by_server("srv-other") == 2
def test_audits_with_bulk_admin_no_upstream(storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
_seed_user_tokens(storage, "srv-oauth", users=2)
client = TestClient(_build_app(storage))
resp = client.post("/v1/api/admin/mcp-servers/srv-oauth/bulk-revoke")
assert resp.status_code == 200
# Pull the most-recent audit row for the bulk_revoked action and
# verify it carries the deferral marker.
events = storage.list_audit_events(limit=10)
bulk_rows = [e for e in events if e.get("action") == "mcp_server.oauth.bulk_revoked"]
assert len(bulk_rows) == 1
detail = bulk_rows[0].get("detail")
if isinstance(detail, str):
import json as _json
detail = _json.loads(detail)
assert detail.get("upstream_revoke_outcome") == "bulk_admin_no_upstream"
assert detail.get("rows_deleted") == 2
assert detail.get("consented_users_before") == 2
assert detail.get("name") == "srv-oauth"
+146 -1
View File
@@ -788,7 +788,11 @@ class TestSessionIntegration:
assert call_id == "call_789"
assert output == "result text"
mock_mcp.call_tool_sync.assert_called_once_with(
"mcp__test__search", {"query": "hello"}, user_id=None, timeout=30
"mcp__test__search",
{"query": "hello"},
user_id=None,
timeout=30,
is_interactive_for_consent=True,
)
def test_exec_mcp_tool_error(self, tmp_db):
@@ -1056,6 +1060,147 @@ class TestRefreshServer:
asyncio.run(_run())
class TestLastRefreshTracking:
"""Phase 9 admin status pill — ``_last_refresh`` is written on every
refresh path so the admin UI reflects manual-refresh AND auto-
reconnect outcomes uniformly. This test class pins the contract.
"""
@staticmethod
def _seed_minimal(mgr: MCPClientManager, name: str = "srv") -> MagicMock:
mock_session = MagicMock()
mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
mock_session.list_resources = AsyncMock(return_value=MagicMock(resources=[]))
mock_session.list_resource_templates = AsyncMock(
return_value=MagicMock(resourceTemplates=[])
)
mock_session.list_prompts = AsyncMock(return_value=MagicMock(prompts=[]))
_seed_static_state(
mgr,
name,
session=mock_session,
tools=[],
supports_resources=True,
supports_prompts=True,
)
return mock_session
def test_last_refresh_written_on_success(self) -> None:
async def _run() -> None:
mgr = MCPClientManager({})
self._seed_minimal(mgr)
assert "srv" not in mgr._last_refresh
await mgr._refresh_server("srv")
entry = mgr._last_refresh.get("srv")
assert entry is not None
ts, outcome = entry
assert outcome == "ok"
assert isinstance(ts, float) and ts > 0
asyncio.run(_run())
def test_last_refresh_written_on_tool_refresh_failure(self) -> None:
"""When ``_refresh_server_tools`` raises, the outcome reflects
the exception class and the exception still propagates."""
async def _run() -> None:
mgr = MCPClientManager({})
mock_session = self._seed_minimal(mgr)
mock_session.list_tools = AsyncMock(side_effect=RuntimeError("upstream down"))
with pytest.raises(RuntimeError, match="upstream down"):
await mgr._refresh_server("srv")
entry = mgr._last_refresh.get("srv")
assert entry is not None
_, outcome = entry
assert outcome == "error:RuntimeError"
asyncio.run(_run())
def test_last_refresh_records_first_exception_when_multiple_fail(
self,
) -> None:
"""``return_exceptions=True`` lets sibling tasks complete; the
outcome reflects the FIRST exception encountered."""
async def _run() -> None:
mgr = MCPClientManager({})
mock_session = self._seed_minimal(mgr)
# Tools succeeds; resources raises first (gather preserves
# argument order in its results list, so resources is the
# first failure regardless of which awaitable finished first
# in wall-clock terms).
mock_session.list_resources = AsyncMock(side_effect=ValueError("res boom"))
mock_session.list_prompts = AsyncMock(side_effect=KeyError("prompts boom"))
with pytest.raises((ValueError, KeyError)):
await mgr._refresh_server("srv")
entry = mgr._last_refresh.get("srv")
assert entry is not None
_, outcome = entry
# Either of the two failing tasks could be "first" in
# gather's results list ordering — the order is positional
# so resources (arg #2) comes before prompts (arg #3).
assert outcome == "error:ValueError"
asyncio.run(_run())
def test_refresh_all_overwrites_stale_ok_on_reconnect_failure(
self,
) -> None:
"""The chokepoint bug-1 fix: a prior successful refresh's ``'ok'``
entry MUST be overwritten when a subsequent reconnect fails
otherwise the admin pill shows misleading "ok" while the server
is in fact broken."""
async def _run() -> None:
mgr = MCPClientManager({})
# Server is configured but has no live session — _refresh_all
# routes to the reconnect branch.
mgr._server_configs["srv"] = {"type": "stdio", "command": "x"}
# Pre-seed a stale "ok" from an earlier successful refresh.
mgr._last_refresh["srv"] = (1000.0, "ok")
async def _raise(*_a: object, **_kw: object) -> None:
raise ConnectionError("reconnect failed")
mgr._connect_one = _raise # type: ignore[assignment]
await mgr._refresh_all("srv")
entry = mgr._last_refresh.get("srv")
assert entry is not None
ts, outcome = entry
# Outcome reflects the new failure, not the stale ok.
assert outcome == "error:ConnectionError"
assert ts > 1000.0
asyncio.run(_run())
def test_get_server_status_surfaces_last_refresh_fields(self) -> None:
"""``get_server_status`` surfaces ``last_refresh_at`` and
``last_refresh_outcome`` for the admin pill null when no
refresh has occurred yet, populated after one."""
mgr = MCPClientManager({})
mgr._server_configs["srv"] = {"type": "stdio", "command": "x"}
# No refresh yet — fields must be present and null so the JS
# renderer can branch on absence cleanly.
status = mgr.get_server_status("srv")
assert status["last_refresh_at"] is None
assert status["last_refresh_outcome"] is None
# Populate the tuple directly and re-read.
mgr._last_refresh["srv"] = (12345.5, "ok")
status = mgr.get_server_status("srv")
assert status["last_refresh_at"] == 12345.5
assert status["last_refresh_outcome"] == "ok"
class TestListeners:
def test_add_and_notify(self):
mgr = MCPClientManager({})
+91
View File
@@ -612,6 +612,97 @@ class TestCallback:
assert plain is not None
assert plain["refresh_token"] is None
def test_callback_clears_pending_consent_on_success(
self, storage: SQLiteBackend, http_client_mock: MagicMock
) -> None:
"""Successful callback must drop any ``mcp_pending_consent`` rows
for the just-consented ``(user, server)`` (Phase 9 lifecycle
contract). Regression guard for the dashboard-stays-stale-after-
consent invariant.
"""
_seed_oauth_user_server(storage)
self._seed_pending(storage)
# Seed a deferred-consent record that a prior non-interactive run
# would have left behind. Plus a cross-tenant record that must
# NOT be touched.
storage.upsert_mcp_pending_consent(
user_id="user-1",
server_name="srv-oauth",
error_code="mcp_consent_required",
scopes_required=None,
last_ws_id="ws-1",
last_tool_call_id="tool-1",
now_iso="2026-05-11T12:00:00",
)
storage.upsert_mcp_pending_consent(
user_id="other-user",
server_name="srv-oauth",
error_code="mcp_consent_required",
scopes_required=None,
last_ws_id=None,
last_tool_call_id=None,
now_iso="2026-05-11T12:00:00",
)
token_store = _make_token_store(storage)
http_client_mock.get.return_value = _mk_response(200, _good_as_metadata_doc())
http_client_mock.post.return_value = _mk_response(
200,
{"access_token": "opaque-aaa", "expires_in": 3600},
)
app = _build_app(storage=storage, http_client=http_client_mock, token_store=token_store)
client = TestClient(app, raise_server_exceptions=False)
with _public_addr_patch():
resp = client.get(
"/v1/api/mcp/oauth/callback?code=c&state=valid-state",
follow_redirects=False,
)
assert resp.status_code == 302
# Callback completed → user-1's deferred-consent row was cleared.
assert storage.list_mcp_pending_consent_by_user("user-1") == []
# Cross-tenant row survives — clear is per-(user, server).
other = storage.list_mcp_pending_consent_by_user("other-user")
assert len(other) == 1
assert other[0]["server_name"] == "srv-oauth"
def test_callback_storage_failure_does_not_block_redirect(
self, storage: SQLiteBackend, http_client_mock: MagicMock
) -> None:
"""If the post-persist ``delete_mcp_pending_consent`` raises, the
callback's redirect still completes (best-effort contract). The
stale badge is preferred over a broken consent flow.
"""
_seed_oauth_user_server(storage)
self._seed_pending(storage)
token_store = _make_token_store(storage)
http_client_mock.get.return_value = _mk_response(200, _good_as_metadata_doc())
http_client_mock.post.return_value = _mk_response(
200,
{"access_token": "opaque-aaa", "expires_in": 3600},
)
app = _build_app(storage=storage, http_client=http_client_mock, token_store=token_store)
client = TestClient(app, raise_server_exceptions=False)
original_delete = storage.delete_mcp_pending_consent
def _raise(*_a: Any, **_kw: Any) -> bool:
raise RuntimeError("storage offline")
storage.delete_mcp_pending_consent = _raise # type: ignore[method-assign]
try:
with _public_addr_patch():
resp = client.get(
"/v1/api/mcp/oauth/callback?code=c&state=valid-state",
follow_redirects=False,
)
finally:
storage.delete_mcp_pending_consent = original_delete # type: ignore[method-assign]
assert resp.status_code == 302
# Token persistence still succeeded — the user-visible contract.
plain = token_store.get_user_token("user-1", "srv-oauth")
assert plain is not None
# ---------------------------------------------------------------------------
# 503 paths when mcp_token_store is None
+304
View File
@@ -0,0 +1,304 @@
"""Boundary tests for the Phase 9 pending-consent write path.
Drives ``MCPClientManager._dispatch_pool_sync`` (and the helper it
calls, ``_record_pending_consent_best_effort``) and asserts that
deferred-consent records reach storage only on non-interactive callers.
Per ``feedback_tests_through_boundaries.md``, at least one test must
drive the real sync dispatcher real ``_is_structured_error``
real ``_record_pending_consent_best_effort`` plumb-through; the
``_helpers`` unit tests below cover the classifier in isolation, but
the end-to-end test is the structural gate that catches
plumb-through regressions.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import threading
from typing import Any
from unittest.mock import patch
import pytest
from tests.conftest import make_mcp_token_cipher
from turnstone.core.mcp_client import (
_PENDING_CONSENT_PERSIST_CODES,
MCPClientManager,
_parse_pending_consent_envelope,
)
from turnstone.core.mcp_crypto import MCPTokenStore
from turnstone.core.mcp_oauth import TokenLookupResult
# ---------------------------------------------------------------------------
# Helper-level unit tests (cheap, no event loop)
# ---------------------------------------------------------------------------
class TestParseEnvelope:
def test_consent_required_no_scopes(self) -> None:
env = json.dumps({"error": {"code": "mcp_consent_required", "server": "x", "detail": "d"}})
assert _parse_pending_consent_envelope(env) == ("mcp_consent_required", None)
def test_insufficient_scope_with_scopes(self) -> None:
env = json.dumps(
{
"error": {
"code": "mcp_insufficient_scope",
"server": "x",
"detail": "d",
"scopes_required": ["read", "write"],
}
}
)
assert _parse_pending_consent_envelope(env) == (
"mcp_insufficient_scope",
["read", "write"],
)
def test_operator_codes_filtered(self) -> None:
# Key-unknown / url-insecure / *_forbidden are operator-actionable,
# NOT user-consent-shaped. They must not produce pending-consent
# rows, regardless of whether the caller is interactive.
for code in (
"mcp_token_undecryptable_key_unknown",
"mcp_oauth_url_insecure",
"mcp_tool_call_forbidden",
"mcp_resource_read_forbidden",
"mcp_prompt_get_forbidden",
):
env = json.dumps({"error": {"code": code, "server": "x", "detail": "d"}})
assert _parse_pending_consent_envelope(env) is None, code
def test_malformed_json_returns_none(self) -> None:
assert _parse_pending_consent_envelope("not json") is None
assert _parse_pending_consent_envelope("") is None
def test_persist_codes_set_is_expected(self) -> None:
# Pin the contract — adding a new persistable code here is a
# deliberate design decision and should require a test update.
assert {
"mcp_consent_required",
"mcp_insufficient_scope",
} == _PENDING_CONSENT_PERSIST_CODES
# ---------------------------------------------------------------------------
# End-to-end plumb-through (drives _dispatch_pool_sync)
# ---------------------------------------------------------------------------
def _seed_oauth_server(backend: Any, *, name: str = "pool-srv") -> None:
backend.create_mcp_server(
server_id="srv-" + name,
name=name,
transport="streamable-http",
command="",
args="[]",
url="https://example.com/mcp",
headers="{}",
env="{}",
auto_approve=False,
enabled=True,
created_by="admin",
)
backend.update_mcp_server("srv-" + name, auth_type="oauth_user")
@pytest.fixture
def running_loop_mgr():
cfg: dict[str, Any] = {}
mgr = MCPClientManager(cfg)
loop = asyncio.new_event_loop()
thread = threading.Thread(target=loop.run_forever, daemon=True, name="phase9-test-loop")
thread.start()
mgr._loop = loop
try:
yield mgr, loop, thread
finally:
async def _drain(m: MCPClientManager) -> None:
task = m._user_pool_eviction_task
if task is not None:
task.cancel()
with contextlib.suppress(BaseException):
await task
m._user_pool_eviction_task = None
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
def _wire_mgr(mgr: MCPClientManager, backend: Any) -> None:
cipher = make_mcp_token_cipher()
from types import SimpleNamespace
from unittest.mock import MagicMock
app_state = SimpleNamespace(
auth_storage=backend,
mcp_token_store=MCPTokenStore(backend, cipher, node_id="test"),
mcp_oauth_http_client=MagicMock(),
mcp_oauth_refresh_locks={},
mcp_oauth_metadata_cache={},
)
mgr.set_storage(backend)
mgr.set_app_state(app_state)
def test_dispatch_persists_pending_for_non_interactive_caller(
running_loop_mgr: Any, backend: Any
) -> None:
"""Non-interactive caller hits ``mcp_consent_required`` → a
``mcp_pending_consent`` row appears for ``(user_id, server_name)``."""
mgr, _loop, _ = running_loop_mgr
_seed_oauth_server(backend)
_wire_mgr(mgr, backend)
async def _missing_token(**kwargs: Any) -> TokenLookupResult:
return TokenLookupResult(kind="missing")
with (
patch(
"turnstone.core.mcp_client.get_user_access_token_classified",
side_effect=_missing_token,
),
pytest.raises(RuntimeError) as exc_info,
):
mgr.call_tool_sync(
"mcp__pool-srv__echo",
{"payload": "hi"},
user_id="user-a",
timeout=10,
is_interactive_for_consent=False,
)
# Structured error envelope surfaces as RuntimeError to the caller.
payload = json.loads(str(exc_info.value)).get("error", {})
assert payload.get("code") == "mcp_consent_required"
# Persistent row written for the dashboard badge.
rows = backend.list_mcp_pending_consent_by_user("user-a")
assert len(rows) == 1
r = rows[0]
assert r["user_id"] == "user-a"
assert r["server_name"] == "pool-srv"
assert r["error_code"] == "mcp_consent_required"
assert r["occurrence_count"] == 1
def test_dispatch_does_not_persist_for_interactive_caller(
running_loop_mgr: Any, backend: Any
) -> None:
"""Interactive caller hits the same error path → NO row written.
Interactive (WEB / CLI) sessions surface the consent prompt in-flight
via the Phase 8 SSE renderer; persisting would just produce
immediately-stale dashboard badges.
"""
mgr, _loop, _ = running_loop_mgr
_seed_oauth_server(backend)
_wire_mgr(mgr, backend)
async def _missing_token(**kwargs: Any) -> TokenLookupResult:
return TokenLookupResult(kind="missing")
with (
patch(
"turnstone.core.mcp_client.get_user_access_token_classified",
side_effect=_missing_token,
),
pytest.raises(RuntimeError),
):
mgr.call_tool_sync(
"mcp__pool-srv__echo",
{"payload": "hi"},
user_id="user-a",
timeout=10,
is_interactive_for_consent=True,
)
assert backend.list_mcp_pending_consent_by_user("user-a") == []
def test_dispatch_returns_envelope_unchanged_on_storage_failure(
running_loop_mgr: Any, backend: Any
) -> None:
"""When ``upsert_mcp_pending_consent`` raises, the agent-observable
contract is unchanged: the structured-error ``RuntimeError`` still
surfaces with the original ``mcp_consent_required`` code. The doc-
string promises best-effort persistence; this test pins that
promise so a regression that propagates the storage exception would
fail visibly.
"""
mgr, _loop, _ = running_loop_mgr
_seed_oauth_server(backend)
_wire_mgr(mgr, backend)
async def _missing_token(**kwargs: Any) -> TokenLookupResult:
return TokenLookupResult(kind="missing")
original_upsert = backend.upsert_mcp_pending_consent
def _raise(*_a: Any, **_kw: Any) -> None:
raise RuntimeError("storage offline")
backend.upsert_mcp_pending_consent = _raise # type: ignore[method-assign]
try:
with (
patch(
"turnstone.core.mcp_client.get_user_access_token_classified",
side_effect=_missing_token,
),
pytest.raises(RuntimeError) as exc_info,
):
mgr.call_tool_sync(
"mcp__pool-srv__echo",
{"payload": "hi"},
user_id="user-a",
timeout=10,
is_interactive_for_consent=False,
)
finally:
backend.upsert_mcp_pending_consent = original_upsert # type: ignore[method-assign]
payload = json.loads(str(exc_info.value)).get("error", {})
assert payload.get("code") == "mcp_consent_required"
def test_dispatch_does_not_persist_for_operator_actionable_code(
running_loop_mgr: Any, backend: Any
) -> None:
"""Decrypt-failure → operator-actionable; even non-interactive callers
must NOT produce a user-facing pending-consent record (the user can't
resolve this by re-consenting).
"""
mgr, _loop, _ = running_loop_mgr
_seed_oauth_server(backend)
_wire_mgr(mgr, backend)
async def _decrypt_failure(**kwargs: Any) -> TokenLookupResult:
return TokenLookupResult(kind="decrypt_failure")
with (
patch(
"turnstone.core.mcp_client.get_user_access_token_classified",
side_effect=_decrypt_failure,
),
pytest.raises(RuntimeError) as exc_info,
):
mgr.call_tool_sync(
"mcp__pool-srv__echo",
{"payload": "hi"},
user_id="user-a",
timeout=10,
is_interactive_for_consent=False,
)
payload = json.loads(str(exc_info.value)).get("error", {})
assert payload.get("code") == "mcp_token_undecryptable_key_unknown"
# The operator-actionable code does NOT produce a pending-consent row.
assert backend.list_mcp_pending_consent_by_user("user-a") == []
+259
View File
@@ -0,0 +1,259 @@
"""HTTP tests for the Phase 9 pending-consent endpoints.
Covers:
- ``GET /v1/api/mcp/oauth/pending`` (install gate + read path)
- ``DELETE /v1/api/mcp/oauth/pending/{server_name}`` (single clear)
- ``DELETE /v1/api/mcp/oauth/pending`` (bulk clear)
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
from turnstone.core.auth import AuthResult
from turnstone.core.mcp_oauth import (
handle_mcp_oauth_clear_all_pending,
handle_mcp_oauth_clear_pending,
handle_mcp_oauth_list_pending,
)
from turnstone.core.storage._sqlite import SQLiteBackend
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
class _InjectAuthMiddleware(BaseHTTPMiddleware):
"""Stamp a fixed authenticated user on every request."""
def __init__(self, app: Any, user_id: str = "user-1") -> None:
super().__init__(app)
self._user_id = user_id
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id=self._user_id,
scopes=frozenset({"write"}),
token_source="config",
permissions=frozenset({"read", "write"}),
)
return await call_next(request)
def _build_app(storage: SQLiteBackend, *, user_id: str = "user-1") -> Starlette:
class _Mw(_InjectAuthMiddleware):
def __init__(self, app: Any) -> None:
super().__init__(app, user_id=user_id)
app = Starlette(
routes=[
Mount(
"/v1",
routes=[
Route("/api/mcp/oauth/pending", handle_mcp_oauth_list_pending),
Route(
"/api/mcp/oauth/pending",
handle_mcp_oauth_clear_all_pending,
methods=["DELETE"],
),
Route(
"/api/mcp/oauth/pending/{server_name}",
handle_mcp_oauth_clear_pending,
methods=["DELETE"],
),
],
),
],
middleware=[Middleware(_Mw)],
)
app.state.auth_storage = storage
return app
@pytest.fixture
def storage(tmp_path: Any) -> SQLiteBackend:
backend = SQLiteBackend(str(tmp_path / "test.db"))
backend.create_user("user-1", "user1", "User One", "hash")
backend.create_user("user-2", "user2", "User Two", "hash")
return backend
def _seed_oauth_server(backend: SQLiteBackend, *, name: str = "srv-x") -> None:
backend.create_mcp_server(
server_id="srv-id-" + name,
name=name,
transport="streamable-http",
url="https://example.com/mcp",
auth_type="oauth_user",
)
def _seed_pending(
backend: SQLiteBackend,
*,
user_id: str = "user-1",
server_name: str = "srv-x",
error_code: str = "mcp_consent_required",
now_iso: str = "2026-05-11T12:00:00",
) -> None:
backend.upsert_mcp_pending_consent(
user_id=user_id,
server_name=server_name,
error_code=error_code,
scopes_required=None,
last_ws_id=None,
last_tool_call_id=None,
now_iso=now_iso,
)
class TestListPending:
def test_install_gate_short_circuits_on_no_oauth_servers(self, storage: SQLiteBackend) -> None:
# Seed a pending row but NO oauth_user MCP server — the gate
# must short-circuit to {pending: 0} regardless.
_seed_pending(storage)
client = TestClient(_build_app(storage))
resp = client.get("/v1/api/mcp/oauth/pending")
assert resp.status_code == 200
assert resp.json() == {"pending": 0, "servers": []}
def test_lists_pending_records_for_authenticated_user(self, storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
_seed_pending(storage)
client = TestClient(_build_app(storage))
resp = client.get("/v1/api/mcp/oauth/pending")
assert resp.status_code == 200
body = resp.json()
assert body["pending"] == 1
assert len(body["servers"]) == 1
assert body["servers"][0]["server_name"] == "srv-x"
assert body["servers"][0]["error_code"] == "mcp_consent_required"
def test_does_not_leak_cross_user_records(self, storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
_seed_pending(storage, user_id="user-2")
client = TestClient(_build_app(storage, user_id="user-1"))
resp = client.get("/v1/api/mcp/oauth/pending")
assert resp.status_code == 200
assert resp.json() == {"pending": 0, "servers": []}
class TestClearPending:
def test_delete_single(self, storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
_seed_pending(storage)
client = TestClient(_build_app(storage))
resp = client.delete("/v1/api/mcp/oauth/pending/srv-x")
assert resp.status_code == 204
assert storage.list_mcp_pending_consent_by_user("user-1") == []
def test_delete_missing_still_returns_204(self, storage: SQLiteBackend) -> None:
# Idempotent — must not leak cross-user existence info via 404.
_seed_oauth_server(storage)
client = TestClient(_build_app(storage))
resp = client.delete("/v1/api/mcp/oauth/pending/never-existed")
assert resp.status_code == 204
def test_delete_does_not_touch_cross_user_rows(self, storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
_seed_pending(storage, user_id="user-1")
_seed_pending(storage, user_id="user-2")
client = TestClient(_build_app(storage, user_id="user-1"))
resp = client.delete("/v1/api/mcp/oauth/pending/srv-x")
assert resp.status_code == 204
# User-2's row survives.
assert len(storage.list_mcp_pending_consent_by_user("user-2")) == 1
class TestAuditTrail:
def test_single_dismiss_audits(self, storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
_seed_pending(storage)
client = TestClient(_build_app(storage))
resp = client.delete("/v1/api/mcp/oauth/pending/srv-x")
assert resp.status_code == 204
events = storage.list_audit_events(limit=10)
rows = [
e for e in events if e.get("action") == "mcp_server.oauth.pending_consent_dismissed"
]
assert len(rows) == 1
detail = rows[0].get("detail")
if isinstance(detail, str):
import json as _json
detail = _json.loads(detail)
assert detail.get("mode") == "single"
assert detail.get("cleared") == 1
def test_single_dismiss_audits_even_when_no_row_existed(self, storage: SQLiteBackend) -> None:
# Cross-tenant non-observability requires a 204 in the never-existed
# case — the audit row distinguishes a real dismiss from a stuffed
# attempt by recording ``cleared=0``.
_seed_oauth_server(storage)
client = TestClient(_build_app(storage))
resp = client.delete("/v1/api/mcp/oauth/pending/never-existed")
assert resp.status_code == 204
events = storage.list_audit_events(limit=10)
rows = [
e for e in events if e.get("action") == "mcp_server.oauth.pending_consent_dismissed"
]
assert len(rows) == 1
detail = rows[0].get("detail")
if isinstance(detail, str):
import json as _json
detail = _json.loads(detail)
assert detail.get("mode") == "single"
assert detail.get("cleared") == 0
def test_bulk_dismiss_audits(self, storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
_seed_oauth_server(storage, name="srv-y")
_seed_pending(storage, server_name="srv-x")
_seed_pending(storage, server_name="srv-y")
client = TestClient(_build_app(storage))
resp = client.delete("/v1/api/mcp/oauth/pending")
assert resp.status_code == 200
assert resp.json() == {"cleared": 2}
events = storage.list_audit_events(limit=10)
rows = [
e for e in events if e.get("action") == "mcp_server.oauth.pending_consent_dismissed"
]
assert len(rows) == 1
detail = rows[0].get("detail")
if isinstance(detail, str):
import json as _json
detail = _json.loads(detail)
assert detail.get("mode") == "bulk"
assert detail.get("cleared") == 2
class TestClearAllPending:
def test_bulk_clear(self, storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
_seed_oauth_server(storage, name="srv-y")
_seed_pending(storage, server_name="srv-x")
_seed_pending(storage, server_name="srv-y")
client = TestClient(_build_app(storage))
resp = client.delete("/v1/api/mcp/oauth/pending")
assert resp.status_code == 200
assert resp.json() == {"cleared": 2}
assert storage.list_mcp_pending_consent_by_user("user-1") == []
def test_bulk_clear_zero_when_empty(self, storage: SQLiteBackend) -> None:
_seed_oauth_server(storage)
client = TestClient(_build_app(storage))
resp = client.delete("/v1/api/mcp/oauth/pending")
assert resp.status_code == 200
assert resp.json() == {"cleared": 0}
+263
View File
@@ -0,0 +1,263 @@
"""Storage CRUD tests for the Phase 9 ``mcp_pending_consent`` table.
Validates protocol additions backing the dashboard pending-consent badge:
- ``upsert_mcp_pending_consent`` insert + on-conflict refresh
- ``list_mcp_pending_consent_by_user`` read path
- ``delete_mcp_pending_consent`` single-row clear
- ``delete_all_mcp_pending_consent_by_user`` bulk clear
- ``count_mcp_consented_users_by_server`` admin status pill
- ``any_oauth_user_mcp_servers`` install-level gate
"""
from __future__ import annotations
def _iso(ts: str = "2026-05-11T12:00:00") -> str:
return ts
class TestUpsertAndList:
def test_insert_round_trip(self, backend) -> None:
backend.upsert_mcp_pending_consent(
user_id="user-a",
server_name="srv-x",
error_code="mcp_consent_required",
scopes_required="read write",
last_ws_id="ws-1",
last_tool_call_id="tool-1",
now_iso=_iso(),
)
rows = backend.list_mcp_pending_consent_by_user("user-a")
assert len(rows) == 1
r = rows[0]
assert r["user_id"] == "user-a"
assert r["server_name"] == "srv-x"
assert r["error_code"] == "mcp_consent_required"
assert r["scopes_required"] == "read write"
assert r["last_ws_id"] == "ws-1"
assert r["last_tool_call_id"] == "tool-1"
assert r["occurrence_count"] == 1
assert r["first_seen_at"] == r["last_seen_at"]
def test_upsert_bumps_count_and_refreshes_recency(self, backend) -> None:
backend.upsert_mcp_pending_consent(
user_id="user-a",
server_name="srv-x",
error_code="mcp_consent_required",
scopes_required=None,
last_ws_id=None,
last_tool_call_id=None,
now_iso="2026-05-11T12:00:00",
)
backend.upsert_mcp_pending_consent(
user_id="user-a",
server_name="srv-x",
error_code="mcp_insufficient_scope",
scopes_required="read",
last_ws_id="ws-2",
last_tool_call_id="tool-2",
now_iso="2026-05-11T13:00:00",
)
rows = backend.list_mcp_pending_consent_by_user("user-a")
assert len(rows) == 1
r = rows[0]
# Recency fields refreshed to the second call's values; count bumped.
assert r["occurrence_count"] == 2
assert r["error_code"] == "mcp_insufficient_scope"
assert r["scopes_required"] == "read"
assert r["last_ws_id"] == "ws-2"
assert r["last_tool_call_id"] == "tool-2"
assert r["last_seen_at"] == "2026-05-11T13:00:00"
# first_seen_at preserved — that's the load-bearing audit value.
assert r["first_seen_at"] == "2026-05-11T12:00:00"
def test_list_orders_by_last_seen_desc(self, backend) -> None:
backend.upsert_mcp_pending_consent(
user_id="user-a",
server_name="srv-old",
error_code="mcp_consent_required",
scopes_required=None,
last_ws_id=None,
last_tool_call_id=None,
now_iso="2026-05-11T10:00:00",
)
backend.upsert_mcp_pending_consent(
user_id="user-a",
server_name="srv-new",
error_code="mcp_consent_required",
scopes_required=None,
last_ws_id=None,
last_tool_call_id=None,
now_iso="2026-05-11T11:00:00",
)
rows = backend.list_mcp_pending_consent_by_user("user-a")
assert [r["server_name"] for r in rows] == ["srv-new", "srv-old"]
def test_per_user_isolation(self, backend) -> None:
backend.upsert_mcp_pending_consent(
user_id="user-a",
server_name="srv",
error_code="mcp_consent_required",
scopes_required=None,
last_ws_id=None,
last_tool_call_id=None,
now_iso=_iso(),
)
assert backend.list_mcp_pending_consent_by_user("user-b") == []
class TestDelete:
def test_delete_single(self, backend) -> None:
backend.upsert_mcp_pending_consent(
user_id="user-a",
server_name="srv-x",
error_code="mcp_consent_required",
scopes_required=None,
last_ws_id=None,
last_tool_call_id=None,
now_iso=_iso(),
)
assert backend.delete_mcp_pending_consent("user-a", "srv-x") is True
assert backend.list_mcp_pending_consent_by_user("user-a") == []
# Second delete returns False (no row).
assert backend.delete_mcp_pending_consent("user-a", "srv-x") is False
def test_delete_missing_returns_false(self, backend) -> None:
assert backend.delete_mcp_pending_consent("never", "missing") is False
def test_delete_all_by_user(self, backend) -> None:
for name in ("srv-a", "srv-b", "srv-c"):
backend.upsert_mcp_pending_consent(
user_id="user-a",
server_name=name,
error_code="mcp_consent_required",
scopes_required=None,
last_ws_id=None,
last_tool_call_id=None,
now_iso=_iso(),
)
# Cross-user row that must NOT be touched.
backend.upsert_mcp_pending_consent(
user_id="user-b",
server_name="srv-z",
error_code="mcp_consent_required",
scopes_required=None,
last_ws_id=None,
last_tool_call_id=None,
now_iso=_iso(),
)
assert backend.delete_all_mcp_pending_consent_by_user("user-a") == 3
assert backend.list_mcp_pending_consent_by_user("user-a") == []
assert len(backend.list_mcp_pending_consent_by_user("user-b")) == 1
class TestCountConsentedUsersByServer:
def _seed_server(self, backend, name: str = "srv-x") -> None:
backend.create_mcp_server(
server_id="srv-id-" + name,
name=name,
transport="streamable-http",
command="",
args="[]",
url="https://example.com/mcp",
headers="{}",
env="{}",
auto_approve=False,
enabled=True,
created_by="admin",
)
backend.update_mcp_server("srv-id-" + name, auth_type="oauth_user")
def test_counts_distinct_non_expired_users(self, backend) -> None:
self._seed_server(backend)
future = "2099-01-01T00:00:00"
backend.create_mcp_user_token(
"alice",
"srv-x",
access_token_ct=b"ct",
refresh_token_ct=None,
expires_at=future,
scopes=None,
as_issuer="https://as.example.com",
audience="https://example.com/mcp",
)
backend.create_mcp_user_token(
"bob",
"srv-x",
access_token_ct=b"ct",
refresh_token_ct=None,
expires_at=None, # null treated as non-expired
scopes=None,
as_issuer="https://as.example.com",
audience="https://example.com/mcp",
)
# Different server — must not count.
self._seed_server(backend, name="srv-y")
backend.create_mcp_user_token(
"carol",
"srv-y",
access_token_ct=b"ct",
refresh_token_ct=None,
expires_at=future,
scopes=None,
as_issuer="https://as.example.com",
audience="https://example.com/mcp",
)
assert backend.count_mcp_consented_users_by_server("srv-x") == 2
assert backend.count_mcp_consented_users_by_server("srv-y") == 1
def test_excludes_expired(self, backend) -> None:
self._seed_server(backend)
backend.create_mcp_user_token(
"alice",
"srv-x",
access_token_ct=b"ct",
refresh_token_ct=None,
expires_at="2020-01-01T00:00:00", # well in the past
scopes=None,
as_issuer="https://as.example.com",
audience="https://example.com/mcp",
)
assert backend.count_mcp_consented_users_by_server("srv-x") == 0
def test_zero_when_no_rows(self, backend) -> None:
assert backend.count_mcp_consented_users_by_server("missing") == 0
class TestInstallGate:
def test_any_oauth_user_returns_false_on_empty(self, backend) -> None:
assert backend.any_oauth_user_mcp_servers() is False
def test_any_oauth_user_ignores_static_rows(self, backend) -> None:
backend.create_mcp_server(
server_id="srv-1",
name="static-only",
transport="streamable-http",
command="",
args="[]",
url="https://example.com",
headers='{"Authorization": "Bearer x"}',
env="{}",
auto_approve=False,
enabled=True,
created_by="admin",
)
assert backend.any_oauth_user_mcp_servers() is False
def test_any_oauth_user_returns_true_when_one_exists(self, backend) -> None:
backend.create_mcp_server(
server_id="srv-2",
name="oauth-srv",
transport="streamable-http",
command="",
args="[]",
url="https://example.com",
headers="{}",
env="{}",
auto_approve=False,
enabled=True,
created_by="admin",
)
backend.update_mcp_server("srv-2", auth_type="oauth_user")
assert backend.any_oauth_user_mcp_servers() is True
+67 -4
View File
@@ -331,7 +331,11 @@ class TestLoadModelRegistry:
api_key="dummy",
model="local-model",
)
assert reg.count == 2 # "openai" + "default"
# The CLI ``"default"`` shim is suppressed once ``[models.*]``
# populates configs — only the explicit alias survives.
assert reg.count == 1
assert reg.has_alias("openai")
assert not reg.has_alias("default")
assert reg.default == "openai"
_, model, _ = reg.resolve()
assert model == "gpt-4o"
@@ -562,7 +566,12 @@ class TestLoadModelRegistryWithDB:
assert cfg.source == "config"
def test_db_only_models_coexist(self) -> None:
"""DB models coexist alongside config.toml models."""
"""DB models coexist alongside config.toml models.
The CLI ``"default"`` shim is suppressed when DB / config models
already populate the registry see
``test_cli_default_shim_skipped_when_db_models_present``.
"""
storage = _MockStorage(
[
{
@@ -586,7 +595,7 @@ class TestLoadModelRegistryWithDB:
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.has_alias("db-only")
assert reg.has_alias("config-only")
assert reg.has_alias("default")
assert not reg.has_alias("default")
assert reg.get_config("db-only").source == "db"
assert reg.get_config("config-only").source == "config"
@@ -606,10 +615,12 @@ class TestLoadModelRegistryWithDB:
}
]
)
# The CLI default shim is suppressed when the DB row populates
# configs, so only the DB-sourced alias exists here.
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.get_config("from-db").source == "db"
assert reg.get_config("default").source == ""
assert not reg.has_alias("default")
def test_disabled_db_models_excluded(self) -> None:
"""Disabled DB models are not loaded."""
@@ -1675,6 +1686,58 @@ class TestLoadModelRegistryDBOnly:
reg = load_model_registry(model="", storage=storage)
assert not reg.has_alias("default")
def test_cli_default_shim_skipped_when_db_models_present(self) -> None:
"""An auto-detected ``--model`` does NOT synthesise a ``default``
alias when the DB already contributes models.
Regression for the silent bypass of ``model.task_alias`` /
``model.plan_alias``: a synthesised ``default`` aliased to whatever
``--base-url`` was at boot leaks into the LLM-visible alias list,
and the LLM picks it for ``task_agent(model="default")`` which
then routes around the operator-configured per-role default.
"""
storage = _MockStorage(
[
{
"alias": "gh200",
"model": "deepseek-ai/DeepSeek-V4-Flash",
"provider": "openai",
"base_url": "http://gh200:8000/v1",
"api_key": "sk-gh200",
"context_window": 1048576,
"capabilities": "{}",
"enabled": True,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry(
base_url="http://flatspark:8000/v1",
api_key="sk-flatspark",
model="qwen3.6-35B-A3B", # populated by ``detect_model``
storage=storage,
)
assert reg.has_alias("gh200")
assert not reg.has_alias("default")
def test_cli_default_shim_skipped_when_config_models_present(self) -> None:
"""Same shim suppression when only ``[models.*]`` populates configs."""
fake_cfg: dict[str, Any] = {
"models": {"local": {"model": "qwen3-32b"}},
}
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
reg = load_model_registry("http://x/v1", "x", "fallback-model")
assert reg.has_alias("local")
assert not reg.has_alias("default")
def test_cli_default_shim_still_fires_when_registry_empty(self) -> None:
"""Single-model CLI mode (no DB, no config.toml [models.*]) keeps
the back-compat ``default`` alias."""
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "lone-model")
assert reg.has_alias("default")
assert reg.get_config("default").model == "lone-model"
# ---------------------------------------------------------------------------
# server._effective_routing / _apply_routing_overrides
+400
View File
@@ -0,0 +1,400 @@
"""Tests for the console-side ``NotifyDispatcher``.
Exercises the dispatcher against the SQLite synthetic-sweep path so the
suite runs without a Postgres dependency. The PG path is shaped the
same way (same handler invocation semantics) the only difference is
the underlying stream's wake-up source, which is covered separately in
``test_storage_notify.py::TestPostgresNotify``.
"""
from __future__ import annotations
import threading
import time
import pytest
@pytest.fixture
def dispatcher_factory(storage):
"""Yield a factory that constructs + tracks dispatchers for teardown."""
from turnstone.console.notify_dispatcher import NotifyDispatcher
created: list[NotifyDispatcher] = []
def _make(*, channels: list[str]) -> NotifyDispatcher:
d = NotifyDispatcher(storage, channels=channels)
created.append(d)
return d
yield _make
for d in created:
d.stop(timeout=2.0)
def _wait_for(predicate, deadline_sec: float = 3.0) -> bool:
"""Poll ``predicate`` until True or timeout. Returns bool."""
deadline = time.monotonic() + deadline_sec
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.02)
return False
def _start_ready(d, *, timeout: float = 5.0) -> None:
"""``d.start()`` + assert the listener is actually listening.
Closes the start-vs-notify race for backends where ``storage.listen``
blocks on the network (Postgres ``LISTEN`` over a fresh psycopg
connection): without the sync, a same-thread ``storage.notify`` can
fire before the LISTEN registers and the notification is lost.
"""
d.start()
if not d.wait_until_ready(timeout=timeout):
msg = f"dispatcher listener did not open within {timeout}s"
raise AssertionError(msg)
class TestSubscribe:
def test_subscribe_registers_handler(self, dispatcher_factory, storage):
d = dispatcher_factory(channels=["alpha"])
seen: list = []
d.subscribe("alpha", lambda n: seen.append(n))
_start_ready(d)
# Fire a notify via the storage layer — dispatcher delivers to handler.
storage.notify("alpha", "hello")
assert _wait_for(lambda: any(n.payload == "hello" for n in seen))
def test_subscribe_undeclared_channel_raises(self, dispatcher_factory):
d = dispatcher_factory(channels=["alpha"])
with pytest.raises(ValueError, match="not declared"):
d.subscribe("beta", lambda n: None)
def test_subscribe_returns_unsubscribe_callable(self, dispatcher_factory, storage):
d = dispatcher_factory(channels=["alpha"])
seen: list = []
unsub = d.subscribe("alpha", lambda n: seen.append(n))
_start_ready(d)
storage.notify("alpha", "first")
assert _wait_for(lambda: any(n.payload == "first" for n in seen))
unsub()
# After unsubscribe, the handler no longer fires. Drain old hits
# so the next notify-vs-handler-count check is unambiguous.
seen.clear()
storage.notify("alpha", "second")
# Give the dispatcher a beat to deliver if it were going to.
time.sleep(0.2)
assert not any(n.payload == "second" for n in seen)
def test_construction_requires_at_least_one_channel(self, storage):
from turnstone.console.notify_dispatcher import NotifyDispatcher
with pytest.raises(ValueError, match="at least one"):
NotifyDispatcher(storage, channels=[])
def test_duplicate_channels_deduplicated(self, dispatcher_factory):
d = dispatcher_factory(channels=["alpha", "alpha", "beta"])
assert d.channels == ["alpha", "beta"]
class TestDispatch:
def test_multiple_handlers_each_invoked(self, dispatcher_factory, storage):
d = dispatcher_factory(channels=["alpha"])
seen_a: list = []
seen_b: list = []
d.subscribe("alpha", lambda n: seen_a.append(n))
d.subscribe("alpha", lambda n: seen_b.append(n))
_start_ready(d)
storage.notify("alpha", "shared")
assert _wait_for(lambda: seen_a and seen_b)
assert seen_a[0].payload == "shared"
assert seen_b[0].payload == "shared"
def test_handler_exception_does_not_break_dispatch(self, dispatcher_factory, storage):
d = dispatcher_factory(channels=["alpha"])
survived: list = []
def _broken(_n):
msg = "boom"
raise RuntimeError(msg)
d.subscribe("alpha", _broken)
d.subscribe("alpha", lambda n: survived.append(n))
_start_ready(d)
storage.notify("alpha", "after_broken")
# The second handler runs even though the first raised.
assert _wait_for(lambda: any(n.payload == "after_broken" for n in survived))
def test_dispatch_filters_by_channel(self, dispatcher_factory, storage):
d = dispatcher_factory(channels=["alpha", "beta"])
seen_a: list = []
seen_b: list = []
d.subscribe("alpha", lambda n: seen_a.append(n))
d.subscribe("beta", lambda n: seen_b.append(n))
_start_ready(d)
storage.notify("alpha", "for_a")
storage.notify("beta", "for_b")
assert _wait_for(lambda: seen_a and seen_b)
assert all(n.payload == "for_a" for n in seen_a)
assert all(n.payload == "for_b" for n in seen_b)
class TestReconnect:
"""Reconnect + synthetic ``reconcile`` notify on stream-open success.
Uses a stub storage that owns its own listen stream so the test can
drive a controlled stream-error sequence the SQLite path can't
raise :class:`NotifyConnectionError`, and the PG path requires a
real database outage to exercise this code, neither of which fits a
unit test. The dispatcher's threading and reconcile-pending logic
are storage-agnostic the dispatcher sees the same
:class:`NotifyStream` Protocol regardless of backend.
"""
def test_reconcile_fires_after_reopen_not_before(self):
from turnstone.console.notify_dispatcher import NotifyDispatcher
from turnstone.core.storage._notify import Notify, NotifyConnectionError
# State machine: open -> first poll raises NotifyConnectionError
# -> dispatcher waits backoff then reopens -> second open's first
# poll blocks forever (test stops the dispatcher before then).
# The fix: synthetic reconcile fires AFTER the second open
# succeeds, not after the first open fails.
sequence: list[str] = []
reopen_event = threading.Event()
class _StubStream:
def __init__(self, fail_first_poll: bool):
self._fail = fail_first_poll
self._closed = False
def poll(self, _timeout):
if self._closed:
return []
if self._fail:
self._fail = False
sequence.append("poll_raises")
msg = "fake-disconnect"
raise NotifyConnectionError(msg)
sequence.append("poll_returns")
# Block until close to simulate a quiet steady-state.
time.sleep(0.5)
return []
def close(self):
self._closed = True
class _StubStorage:
def __init__(self):
self._open_count = 0
def listen(self, _channels):
import contextlib as _contextlib
@_contextlib.contextmanager
def _cm():
self._open_count += 1
sequence.append(f"open_{self._open_count}")
if self._open_count == 2:
reopen_event.set()
stream = _StubStream(fail_first_poll=(self._open_count == 1))
try:
yield stream
finally:
stream.close()
return _cm()
# Speed up backoff so the reopen happens promptly in the test.
import turnstone.console.notify_dispatcher as nd_mod
original_backoff = nd_mod._RECONNECT_BACKOFF_INITIAL
nd_mod._RECONNECT_BACKOFF_INITIAL = 0.05
try:
d = NotifyDispatcher(_StubStorage(), channels=["alpha"])
got: list[Notify] = []
d.subscribe("alpha", lambda n: got.append(n))
d.start()
try:
# Wait for the second open (post-reconnect).
assert reopen_event.wait(3.0), "dispatcher did not reopen after disconnect"
# Reconcile should be delivered shortly after the reopen.
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
if any(n.payload == "reconcile" for n in got):
break
time.sleep(0.02)
assert any(n.payload == "reconcile" for n in got), (
f"no reconcile delivered; sequence={sequence}, got={got}"
)
# The reconcile must NOT fire before the second open —
# if it did, the index of 'open_2' in sequence would
# come after any reconcile-emitting work. Check ordering:
# 'open_1' < 'poll_raises' < 'open_2' (synthesize happens
# inside the with-block of the SECOND open).
ix_open_1 = sequence.index("open_1")
ix_raises = sequence.index("poll_raises")
ix_open_2 = sequence.index("open_2")
assert ix_open_1 < ix_raises < ix_open_2
finally:
d.stop(timeout=2.0)
finally:
nd_mod._RECONNECT_BACKOFF_INITIAL = original_backoff
def test_generic_exception_path_also_synthesizes_reconcile(self):
"""Exceptions thrown during ``listen()`` (not via stream.poll) still trigger reconcile.
Models the ``psycopg.connect()`` / initial ``LISTEN`` failure
shape, which doesn't go through the stream's exception
translator and would hit the generic ``except Exception``
branch. Pre-fix, that branch emitted no reconcile.
"""
from turnstone.console.notify_dispatcher import NotifyDispatcher
reopen_event = threading.Event()
class _StubStream:
def __init__(self):
self._closed = False
def poll(self, _timeout):
if self._closed:
return []
time.sleep(0.5)
return []
def close(self):
self._closed = True
class _StubStorage:
def __init__(self):
self._open_count = 0
def listen(self, _channels):
import contextlib as _contextlib
self._open_count += 1
if self._open_count == 1:
# First open raises a generic exception (e.g.
# ``psycopg.OperationalError`` from a failed connect)
# — landing in the dispatcher's generic except branch.
msg = "fake-connect-failure"
raise RuntimeError(msg)
@_contextlib.contextmanager
def _cm():
reopen_event.set()
stream = _StubStream()
try:
yield stream
finally:
stream.close()
return _cm()
import turnstone.console.notify_dispatcher as nd_mod
original_backoff = nd_mod._RECONNECT_BACKOFF_INITIAL
nd_mod._RECONNECT_BACKOFF_INITIAL = 0.05
try:
d = NotifyDispatcher(_StubStorage(), channels=["alpha"])
got: list = []
d.subscribe("alpha", lambda n: got.append(n))
d.start()
try:
assert reopen_event.wait(3.0), "dispatcher did not reopen after generic exception"
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
if any(n.payload == "reconcile" for n in got):
break
time.sleep(0.02)
assert any(n.payload == "reconcile" for n in got), (
"no reconcile delivered after generic-exception recovery"
)
finally:
d.stop(timeout=2.0)
finally:
nd_mod._RECONNECT_BACKOFF_INITIAL = original_backoff
class TestCoalescing:
"""Same-channel burst collapses to one handler invocation per batch."""
def test_burst_coalesces_to_one_handler_call_per_channel(self, dispatcher_factory, storage):
d = dispatcher_factory(channels=["alpha"])
invocations: list = []
# Slow handler to ensure all bursts queue up before the first
# call returns — gives the dispatch loop time to coalesce.
coalesce_gate = threading.Event()
def _slow_handler(n):
invocations.append(n)
coalesce_gate.wait(0.05)
d.subscribe("alpha", _slow_handler)
_start_ready(d)
# Burst of 10 notifies on the same channel — should coalesce
# down to many fewer handler invocations.
for i in range(10):
storage.notify("alpha", str(i))
# Wait until the dispatch settles (handler is called at least once
# and the queue empties).
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
if invocations and d._dispatch_queue.empty():
time.sleep(0.1) # allow any final coalesced call to land
break
time.sleep(0.02)
coalesce_gate.set()
# At least one handler call; well fewer than 10 (coalescing
# collapsed the burst). Exact count depends on timing — typical
# is 1-2 invocations per burst on a fast machine.
assert invocations, "handler never fired"
assert len(invocations) < 10, (
f"expected coalescing to collapse burst of 10; got {len(invocations)} invocations"
)
class TestLifecycle:
def test_start_is_idempotent(self, dispatcher_factory):
d = dispatcher_factory(channels=["alpha"])
d.start()
d.start() # No-op, no thread doubling
# Single listener + single dispatch thread are spawned regardless.
# Inspect by name so we don't depend on the exact thread count of
# the test runner.
listener_threads = [
t for t in threading.enumerate() if t.name == "notify-dispatcher-listener"
]
dispatch_threads = [
t for t in threading.enumerate() if t.name == "notify-dispatcher-dispatch"
]
assert len(listener_threads) == 1
assert len(dispatch_threads) == 1
def test_stop_is_idempotent(self, dispatcher_factory):
d = dispatcher_factory(channels=["alpha"])
d.start()
d.stop(timeout=2.0)
d.stop(timeout=2.0) # No-op, no error
def test_stop_without_start_is_noop(self, dispatcher_factory):
d = dispatcher_factory(channels=["alpha"])
d.stop(timeout=1.0) # No-op, no thread to join
def test_stop_joins_threads(self, dispatcher_factory):
d = dispatcher_factory(channels=["alpha"])
d.start()
# Capture thread references then stop and assert they exited.
threads_before = [
t
for t in threading.enumerate()
if t.name in {"notify-dispatcher-listener", "notify-dispatcher-dispatch"}
]
assert threads_before
d.stop(timeout=3.0)
time.sleep(0.05)
for t in threads_before:
assert not t.is_alive(), f"{t.name} still alive after stop"
+35 -6
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import threading
import pytest
@@ -401,8 +402,10 @@ class TestValidation:
class TestValidUntil:
"""``valid_until`` predicate: drain re-checks freshness; falsy /
raising predicates drop the entry without delivery.
"""``valid_until`` predicate: drain re-checks freshness. Falsy
predicates drop the entry without delivery and log at ``info``
(normal lifecycle outcome); raising predicates drop the entry and
log at ``warning`` with ``exc_info`` (misbehaving predicate).
"""
def test_valid_until_true_delivers(self):
@@ -411,26 +414,52 @@ class TestValidUntil:
out = q.drain({"any"})
assert out == [("a", "1", None)]
def test_valid_until_false_drops_silently(self):
def test_valid_until_false_drops_with_info_log(self, caplog: pytest.LogCaptureFixture):
q = NudgeQueue()
q.enqueue("a", "1", "any", valid_until=lambda: False)
out = q.drain({"any"})
with caplog.at_level(logging.INFO, logger="turnstone.core.nudge_queue"):
out = q.drain({"any"})
assert out == []
# Already removed from queue (drain partition removes BEFORE
# predicate check — falsy doesn't return to queue).
assert len(q) == 0
# The drop emits a structured info record so a wiring
# regression (a predicate that always returns False) is still
# observable, without spamming ``warning`` for the routine
# lifecycle case where ``valid_until`` is doing its job.
# structlog renders the event name + extras into ``msg`` as a
# single rendered string, so substring-match like the
# ``watch_dispatch.queue_full`` assertion in
# tests/test_watch_dispatch.py.
drops = [r for r in caplog.records if "nudge_queue.predicate_dropped" in r.getMessage()]
assert len(drops) == 1
assert drops[0].levelno == logging.INFO
assert "predicate_false" in drops[0].getMessage()
assert "'nudge_type': 'a'" in drops[0].getMessage()
assert "'channel': 'any'" in drops[0].getMessage()
assert "'text_len': 1" in drops[0].getMessage()
def test_valid_until_exception_drops_silently(self):
def test_valid_until_exception_drops_with_warning(self, caplog: pytest.LogCaptureFixture):
q = NudgeQueue()
def boom() -> bool:
raise RuntimeError("predicate crash")
q.enqueue("a", "1", "any", valid_until=boom)
out = q.drain({"any"})
with caplog.at_level(logging.WARNING, logger="turnstone.core.nudge_queue"):
out = q.drain({"any"})
assert out == []
# Crash-on-predicate is treated as "no longer valid" — drop, not propagate.
assert len(q) == 0
# Stays at ``warning`` (with ``exc_info``) because a raising
# predicate is a bug, not a normal lifecycle outcome.
drops = [r for r in caplog.records if "nudge_queue.predicate_dropped" in r.getMessage()]
assert len(drops) == 1
assert drops[0].levelno == logging.WARNING
rendered = drops[0].getMessage()
assert "predicate_raised" in rendered
assert "RuntimeError" in rendered
assert "predicate crash" in rendered
def test_valid_until_evaluated_outside_lock(self):
"""The predicate may do non-trivial work (e.g. storage I/O)
@@ -331,3 +331,73 @@ class TestReasoningAuditLogDiscipline:
f"AnthropicProvider._convert_messages strip predicate leaked "
f"reasoning text into INFO+ logs: {offending}"
)
def test_attach_vllm_chat_reasoning_field_does_not_log_reasoning(self) -> None:
"""Phase 5 surface — ``attach_vllm_chat_reasoning_field`` extracts
persisted reasoning text and attaches it as a ``reasoning`` field
on the outgoing assistant message dict. The attached text is
wire-bound (vLLM template render) and UI-bound (history rehydration
already covered by Phase 1 tests above), but MUST NOT appear in
any INFO+ log call along the way."""
from turnstone.core.history_decoration import attach_vllm_chat_reasoning_field
captured, patchers = _capture_log_calls()
for p in patchers:
p.start()
try:
messages = [self._thinking_msg(_MARKER)]
out = attach_vllm_chat_reasoning_field(messages)
# Wire-bound attach succeeded — marker IS allowed in the
# returned dict's reasoning field.
assert out[0]["reasoning"] == _MARKER
finally:
for p in patchers:
p.stop()
offending = [
(lvl, args, kwargs)
for lvl, args, kwargs in captured
if _payload_contains_marker(args, kwargs)
]
assert offending == [], (
f"attach_vllm_chat_reasoning_field leaked reasoning text into INFO+ logs: {offending}"
)
def test_maybe_attach_vllm_chat_reasoning_does_not_log_reasoning(self) -> None:
"""Phase 5 gate method on ChatSession — the session-level
composite gate calls ``attach_vllm_chat_reasoning_field`` when
all 3 conditions pass. Pin that the gate path itself doesn't
log reasoning text (the registry / capability lookups happen
adjacent to the reasoning bytes; a defensive ``log.warning``
showing the message dict on an error path would silently
violate the contract)."""
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
session = make_session()
session._registry = SimpleNamespace(
get_config=lambda _alias: SimpleNamespace(
replay_reasoning_to_model=True,
capabilities={},
server_compat={"server_type": "vllm"},
)
)
session._model_alias = "qwen3"
provider = OpenAIChatCompletionsProvider()
captured, patchers = _capture_log_calls()
for p in patchers:
p.start()
try:
out = session._maybe_attach_vllm_chat_reasoning([self._thinking_msg(_MARKER)], provider)
assert out[0]["reasoning"] == _MARKER
finally:
for p in patchers:
p.stop()
offending = [
(lvl, args, kwargs)
for lvl, args, kwargs in captured
if _payload_contains_marker(args, kwargs)
]
assert offending == [], (
f"ChatSession._maybe_attach_vllm_chat_reasoning leaked reasoning "
f"text into INFO+ logs: {offending}"
)
+668 -19
View File
@@ -255,12 +255,17 @@ function makeEl(tag) {
setAttribute(k, v) { this._attrs[k] = v; },
getAttribute(k) { return this._attrs[k] !== undefined ? this._attrs[k] : null; },
get classList() {
// Real DOMTokenList is array-like (length + indexed access) AND
// exposes add/remove/contains. The hljs language-extraction
// loop reads .length + [j], so we return a fresh Array snapshot
// each get + bolt the mutator methods on. add/remove operate on
// the live _classes set so subsequent reads see updates.
const self = this;
return {
add(...c) { c.forEach(x => self._classes.add(x)); },
remove(...c) { c.forEach(x => self._classes.delete(x)); },
contains(c) { return self._classes.has(c); },
};
const arr = Array.from(self._classes);
arr.add = (...c) => c.forEach((x) => self._classes.add(x));
arr.remove = (...c) => c.forEach((x) => self._classes.delete(x));
arr.contains = (c) => self._classes.has(c);
return arr;
},
get className() { return Array.from(this._classes).join(' '); },
set className(v) {
@@ -269,9 +274,33 @@ function makeEl(tag) {
get textContent() {
return this._textContent || this.children.map(c => c.textContent || '').join('');
},
set textContent(v) { this._textContent = v; this.children = []; },
set textContent(v) {
// Real DOM: assigning textContent ALSO replaces innerHTML with
// an entity-escaped representation of the same text. escapeHtml
// (utils.js) round-trips via this side effect without it,
// every escapeHtml() call returns '' and renderMarkdown emits
// empty <p> tags.
this._textContent = v;
this.children = [];
this._innerHTML = String(v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
},
get innerHTML() { return this._innerHTML; },
set innerHTML(v) { this._innerHTML = v; this.children = []; },
set innerHTML(v) {
// Real DOM invalidates the previous textContent when innerHTML
// is replaced leaving _textContent intact would return stale
// data from subsequent textContent reads and mask bugs that
// depend on innerHTML/textContent consistency. We don't HTML-
// parse here, so the cheap correct behavior is to clear
// _textContent and let the children-derived fallback in the
// textContent getter (which is empty after this children = [])
// take over.
this._innerHTML = v;
this.children = [];
this._textContent = '';
},
get isConnected() {
// In real DOM this checks attachment to the document; for the
// test harness we approximate via the parent chain. After
@@ -304,17 +333,28 @@ function makeEl(tag) {
this.parent = null;
},
querySelectorAll(selector) {
// Only supports the literal "pre code.language-mermaid"
// selector that postRenderMermaid uses.
// Supports the two selectors the post-render passes use:
// "pre code.language-mermaid" (postRenderMermaid)
// "pre code[class*='language-']" (postRenderHljs)
const out = [];
const wantsMermaid = selector === "pre code.language-mermaid";
function matchesLangAttr(el) {
for (const cls of el._classes) {
if (cls.startsWith('language-')) return true;
}
return false;
}
function walk(node) {
for (const c of (node.children || [])) {
if (
const isCodeInPre =
c.tagName === 'CODE' &&
c.parent && c.parent.tagName === 'PRE' &&
c._classes.has('language-mermaid')
) {
out.push(c);
c.parent && c.parent.tagName === 'PRE';
if (isCodeInPre) {
if (wantsMermaid) {
if (c._classes.has('language-mermaid')) out.push(c);
} else if (matchesLangAttr(c)) {
out.push(c);
}
}
walk(c);
}
@@ -352,6 +392,20 @@ global.mermaid = {
},
};
// hljs stub. highlightElement mutates the element in place: replaces
// innerHTML with a deterministic synthetic span keyed by the source,
// and adds the hljs class same surface postRenderHljs depends on.
// hljsHighlightCallCount lets tests assert "ran N times" semantics.
let hljsHighlightCallCount = 0;
global.hljs = {
configure: () => {},
highlightElement: (el) => {
hljsHighlightCallCount++;
el._classes.add('hljs');
el._innerHTML = '<span class="hljs-tok">' + el._textContent + '</span>';
},
};
vm.runInThisContext(fs.readFileSync(%(utils)s, 'utf8'));
vm.runInThisContext(fs.readFileSync(%(renderer)s, 'utf8'));
@@ -514,7 +568,7 @@ def test_mermaid_cache_evicts_oldest_at_cap() -> None:
scenario = """
const cap = _MERMAID_CACHE_MAX;
for (let i = 0; i < cap + 5; i++) {
_cacheMermaidEntry(_mermaidSvgCache, 'src-' + i, {svg: 'svg-' + i, bindFunctions: null});
_cacheFifoEntry(_mermaidSvgCache, 'src-' + i, {svg: 'svg-' + i, bindFunctions: null}, cap);
}
process.stdout.write(JSON.stringify({
size: _mermaidSvgCache.size,
@@ -536,10 +590,10 @@ def test_mermaid_overwrite_does_not_evict() -> None:
const cap = _MERMAID_CACHE_MAX;
// Fill exactly to cap.
for (let i = 0; i < cap; i++) {
_cacheMermaidEntry(_mermaidSvgCache, 'src-' + i, {svg: 'svg-' + i, bindFunctions: null});
_cacheFifoEntry(_mermaidSvgCache, 'src-' + i, {svg: 'svg-' + i, bindFunctions: null}, cap);
}
// Overwrite an existing entry must not evict src-0.
_cacheMermaidEntry(_mermaidSvgCache, 'src-5', {svg: 'svg-updated', bindFunctions: null});
_cacheFifoEntry(_mermaidSvgCache, 'src-5', {svg: 'svg-updated', bindFunctions: null}, cap);
process.stdout.write(JSON.stringify({
size: _mermaidSvgCache.size,
hasOldest: _mermaidSvgCache.has('src-0'),
@@ -558,8 +612,8 @@ def test_mermaid_cache_cleared_on_init() -> None:
the rendered output depends on themeVariables which change
on init."""
scenario = """
_cacheMermaidEntry(_mermaidSvgCache, 'src-1', {svg: 'old', bindFunctions: null});
_cacheMermaidEntry(_mermaidErrorCache, 'src-bad', 'old error');
_cacheFifoEntry(_mermaidSvgCache, 'src-1', {svg: 'old', bindFunctions: null}, _MERMAID_CACHE_MAX);
_cacheFifoEntry(_mermaidErrorCache, 'src-bad', 'old error', _MERMAID_CACHE_MAX);
_initMermaid();
process.stdout.write(JSON.stringify({
svgSize: _mermaidSvgCache.size,
@@ -624,3 +678,598 @@ def test_streaming_render_invokes_mermaid_post_render() -> None:
"_streamingRenderApply must call postRenderMermaid for "
"progressive diagram rendering during streaming"
)
# ---------------------------------------------------------------------------
# _normalizeMermaidSource — autoquote labels with bare shape-delimiter
# chars. Mermaid rejects unquoted ( ) [ ] { } inside other labels with
# a "got 'PS'" parse error (paren-start in shape context). The two
# diagrams in the screenshot regression case are encoded here verbatim.
# ---------------------------------------------------------------------------
def _run_normalize(source: str) -> str:
"""Drive _normalizeMermaidSource against the JS harness and return
its output. The function is pure, so no container / mermaid stub
setup is required."""
scenario = f"""
const input = {json.dumps(source)};
const output = _normalizeMermaidSource(input);
process.stdout.write(JSON.stringify({{ output: output }}));
"""
out = _run_mermaid_scenario(scenario)
return str(out["output"])
# Diagram 1 from the screenshot regression — unquoted edge labels with
# parens and <br/> markers. Mermaid rejects both edge labels with
# "got 'PS'"; quoting them resolves it.
_SCREENSHOT_DIAGRAM_1_IN = (
"flowchart LR\n"
' A["vllm-openai:nightly<br/>commit 5536fc0c0<br/>2026-05-11 11:59"]'
" -->|22 upstream<br/>main commits<br/>(10 csrc, but<br/>no new bindings)|"
' B["fork merge_base<br/>7863fff6e5<br/>2026-05-12 00:27"]\n'
" B -->|13 jasl patches<br/>(Python only:<br/>tunings, kernels,"
"<br/>warmup, etc.)|"
' C["ds4-sm120-preview-dev<br/>acc3455b1e"]'
)
_SCREENSHOT_DIAGRAM_1_OUT = (
"flowchart LR\n"
' A["vllm-openai:nightly<br/>commit 5536fc0c0<br/>2026-05-11 11:59"]'
' -->|"22 upstream<br/>main commits<br/>(10 csrc, but<br/>no new bindings)"|'
' B["fork merge_base<br/>7863fff6e5<br/>2026-05-12 00:27"]\n'
' B -->|"13 jasl patches<br/>(Python only:<br/>tunings, kernels,'
'<br/>warmup, etc.)"|'
' C["ds4-sm120-preview-dev<br/>acc3455b1e"]'
)
# Diagram 2 from the screenshot regression — unquoted RECTANGLE node
# label `D[untouched<br/>(.so, _version.py,<br/>install-vendored)]`.
# Same parser failure mode; quoting the bracket label fixes it.
_SCREENSHOT_DIAGRAM_2_IN = (
"flowchart LR\n"
" A[nightly's vllm/<br/>installed package] --> B{tar -xf<br/>fork-vllm.tar}\n"
" B -->|in archive| C[overwritten with<br/>fork's version]\n"
" B -->|not in archive| D[untouched<br/>(.so, _version.py,"
"<br/>install-vendored)]\n"
" E[explicit rm of 1 file<br/>deleted upstream] --> B"
)
_SCREENSHOT_DIAGRAM_2_OUT = (
"flowchart LR\n"
" A[nightly's vllm/<br/>installed package] --> B{tar -xf<br/>fork-vllm.tar}\n"
" B -->|in archive| C[overwritten with<br/>fork's version]\n"
' B -->|not in archive| D["untouched<br/>(.so, _version.py,'
'<br/>install-vendored)"]\n'
" E[explicit rm of 1 file<br/>deleted upstream] --> B"
)
@pytest.mark.parametrize(
("source", "expected"),
[
(_SCREENSHOT_DIAGRAM_1_IN, _SCREENSHOT_DIAGRAM_1_OUT),
(_SCREENSHOT_DIAGRAM_2_IN, _SCREENSHOT_DIAGRAM_2_OUT),
],
)
def test_mermaid_autoquote_fixes_screenshot_diagrams(source: str, expected: str) -> None:
"""The two exact diagrams from the screenshot regression. If
these stop being rewritten with quoted labels, mermaid will
again reject them with `Expecting ... got 'PS'` during live
streaming."""
assert _run_normalize(source) == expected
@pytest.mark.parametrize(
"source",
[
# Clean diagram — no shape delimiters in any label.
"graph TD\n A[foo] --> B[bar]",
# Edge label with no special chars.
"A --> B\nA -->|plain text| B",
# Already-correctly-quoted node label.
'A["already (quoted)"] --> B',
# Already-correctly-quoted edge label.
'A -->|"already (quoted)"| B',
# Cylinder shape — inner () is part of the shape syntax.
"A[(database)] --> B",
# Subroutine shape — inner [] is part of the shape syntax.
"A[[subroutine]] --> B",
# Trapezoid shape — inner / is part of the shape syntax.
"A[/trapezoid/] --> B",
# Reverse trapezoid.
"A[\\trap\\] --> B",
# Mermaid directive — braces here are config, not a label.
'%%{init: {"theme": "dark"}}%%\ngraph TD\n A --> B',
# <br/> tags on their own don't trip quoting.
"A[line1<br/>line2] --> B",
# Sequence diagram — different grammar; we only target labels
# in shape/edge syntax that match the regex anchors.
"sequenceDiagram\n A->>B: hello",
],
)
def test_mermaid_autoquote_leaves_valid_source_alone(source: str) -> None:
"""The autoquoter must not rewrite syntactically valid Mermaid —
a false positive here would break a working diagram. Each case
covers a syntax form whose delimiters are intentional and must
not be wrapped."""
assert _run_normalize(source) == source
def test_mermaid_autoquote_edge_label_with_parens() -> None:
"""Bare-parens edge label gets wrapped. The bare `(` would
otherwise re-enter Mermaid's shape parser."""
src = "A -->|note (with parens)| B"
assert _run_normalize(src) == 'A -->|"note (with parens)"| B'
def test_mermaid_autoquote_node_label_with_parens() -> None:
"""Bare-parens node label gets wrapped."""
src = "D[label (foo, bar)]"
assert _run_normalize(src) == 'D["label (foo, bar)"]'
def test_mermaid_autoquote_node_label_with_braces() -> None:
"""Bare-braces in a rectangle label get wrapped. (Diamond {}
shapes are left alone only single-bracket [] labels are
rewritten.)"""
src = "A[config {key: value}]"
assert _run_normalize(src) == 'A["config {key: value}"]'
def test_mermaid_autoquote_preserves_br_tag_with_parens() -> None:
"""`<br/>` inside a label that also has parens stays — only the
quoting needs to be added around the whole label."""
src = "A[line1<br/>(line2)] --> B"
assert _run_normalize(src) == 'A["line1<br/>(line2)"] --> B'
def test_mermaid_autoquote_skips_label_with_internal_quote() -> None:
"""If a label contains a literal `"`, wrapping would produce
nested unescaped quotes. The autoquoter must punt leaving the
parse error to surface, rather than silently producing a worse
one."""
src = 'A[he said "hi" (lol)]'
assert _run_normalize(src) == src
def test_mermaid_autoquote_multiple_edges_on_one_line() -> None:
"""Both edge labels on a single line get rewritten independently."""
src = "A -->|first (paren)| B -->|second (paren)| C"
expected = 'A -->|"first (paren)"| B -->|"second (paren)"| C'
assert _run_normalize(src) == expected
def test_mermaid_autoquote_normalized_source_hits_cache() -> None:
"""The SVG cache keys on the normalized source — same malformed
input that the LLM streamed earlier still hits the cache on
re-render rather than re-invoking mermaid.render every tick."""
bad = "A[label (with parens)] --> B"
scenario = (
_build_mermaid_container_js([bad])
+ _MERMAID_DRAIN_JS
+ """
postRenderMermaid(container);
setTimeout(() => setTimeout(() => {
const container2 = buildContainer(sources);
postRenderMermaid(container2);
setTimeout(() => {
process.stdout.write(JSON.stringify({
renderCalls: renderCallCount,
normalized: container.children[0]._attrs['data-mermaid-source'],
}));
}, 0);
}, 0), 0);
"""
)
out = _run_mermaid_scenario(scenario)
assert out["renderCalls"] == 1, "second render bypassed the cache"
assert out["normalized"] == 'A["label (with parens)"] --> B'
def test_mermaid_normalize_memo_populates_on_first_call() -> None:
"""First postRenderMermaid call populates _mermaidNormalizeCache
with a rawnormalized entry. A second call on identical raw
textContent then hits the memo (size stays at 1, no second
normalize call), which is the perf-1 fix avoids re-running
split + per-line regex per rAF tick when the diagram hasn't
changed."""
bad = "A[label (with parens)] --> B"
scenario = (
_build_mermaid_container_js([bad])
+ _MERMAID_DRAIN_JS
+ """
postRenderMermaid(container);
const sizeAfterFirst = _mermaidNormalizeCache.size;
const cachedNorm = _mermaidNormalizeCache.get(sources[0]);
// Re-render on a fresh container with the same source.
const container2 = buildContainer(sources);
postRenderMermaid(container2);
setTimeout(() => setTimeout(() => {
process.stdout.write(JSON.stringify({
sizeAfterFirst: sizeAfterFirst,
cachedNorm: cachedNorm,
sizeAfterSecond: _mermaidNormalizeCache.size,
}));
}, 0), 0);
"""
)
out = _run_mermaid_scenario(scenario)
assert out["sizeAfterFirst"] == 1, "first call didn't populate normalize memo"
assert out["cachedNorm"] == 'A["label (with parens)"] --> B'
assert out["sizeAfterSecond"] == 1, (
"second call added a new entry — memo missed on identical source"
)
def test_mermaid_normalize_memo_is_consulted_before_normalize() -> None:
"""Pre-seed _mermaidNormalizeCache with a sentinel value for a
raw source. postRenderMermaid must use the sentinel rather than
re-running _normalizeMermaidSource. Catches a regression where
the memo gets populated but the lookup path is skipped."""
bad = "A[label (with parens)] --> B"
sentinel = "SENTINEL_FROM_MEMO --> X"
raw_js = json.dumps(bad)
sentinel_js = json.dumps(sentinel)
scenario = (
_build_mermaid_container_js([bad])
+ _MERMAID_DRAIN_JS
+ f"""
_mermaidNormalizeCache.set({raw_js}, {sentinel_js});
postRenderMermaid(container);
setTimeout(() => setTimeout(() => {{
process.stdout.write(JSON.stringify({{
sourceAttr: container.children[0]._attrs['data-mermaid-source'],
}}));
}}, 0), 0);
"""
)
out = _run_mermaid_scenario(scenario)
assert out["sourceAttr"] == sentinel, (
"postRenderMermaid bypassed the normalize memo and re-ran normalize"
)
def test_mermaid_normalize_memo_distinct_sources_cache_separately() -> None:
"""Two distinct raw sources produce two memo entries. Confirms
the memo keys on raw textContent, not on something coarser like
container identity."""
bad1 = "A[label (with parens)] --> B"
bad2 = "C[other (label)] --> D"
scenario = (
_build_mermaid_container_js([bad1, bad2])
+ _MERMAID_DRAIN_JS
+ """
postRenderMermaid(container);
setTimeout(() => setTimeout(() => {
process.stdout.write(JSON.stringify({
size: _mermaidNormalizeCache.size,
hasBad1: _mermaidNormalizeCache.has(sources[0]),
hasBad2: _mermaidNormalizeCache.has(sources[1]),
}));
}, 0), 0);
"""
)
out = _run_mermaid_scenario(scenario)
assert out["size"] == 2
assert out["hasBad1"] is True
assert out["hasBad2"] is True
# ---------------------------------------------------------------------------
# Code-fence pairing — close requires \n / EOS, content can't cross
# another close-pattern. Repros the streaming bug where ```mermaid +
# later ```python were paired by the regex, handing mermaid a
# truncated source.
# ---------------------------------------------------------------------------
def _render_md(source: str) -> str:
"""Drive renderMarkdown against the JS harness and return the
rendered HTML. The function is a pure string transform; no DOM
container scaffolding is required."""
scenario = f"""
const input = {json.dumps(source)};
const output = renderMarkdown(input);
process.stdout.write(JSON.stringify({{ output: output }}));
"""
out = _run_mermaid_scenario(scenario)
return str(out["output"])
_FENCE = "```"
def test_fence_partial_open_emits_no_code_block() -> None:
"""While a fence is still open and there's no other ``` later in
the buffer, no <code> block is emitted the open fence stays as
plain markdown text until the real close arrives."""
src = "Intro\n" + _FENCE + 'mermaid\nA["x"] -->|note (with parens)| B["y"]\nstill streaming'
html = _render_md(src)
assert "<code" not in html, f"open fence should not emit <code> mid-stream: {html!r}"
def test_fence_partial_with_later_open_does_not_pair_wrongly() -> None:
"""Before the fence-pair fix: an unclosed ```mermaid followed by
a ```python (also unclosed) would have paired up as
<code class=mermaid>...</code>python..., handing mermaid a
truncated source. With the new regex, neither fence emits a
block until its OWN closing line arrives."""
src = "Intro\n" + _FENCE + "mermaid\nA --> B\n" + _FENCE + 'python\nprint("hi")'
html = _render_md(src)
assert 'class="language-mermaid"' not in html, (
f"mermaid fence should not emit while open: {html!r}"
)
assert 'class="language-python"' not in html, (
f"python fence should not emit while open: {html!r}"
)
def test_fence_close_paired_with_next_open_is_rejected() -> None:
"""Repro of the live-streaming failure: mermaid fence open, then
```python opens and ``` closes the python block. Without the
fix, the regex paired mermaid's open with python's *open* (or
backtracked all the way to python's close), producing
<code class=mermaid>truncated</code>. With the fix mermaid stays
open (content can't cross another \\1 run; close must be at line
boundary) and only python's pair matches."""
src = (
"Intro\n"
+ _FENCE
+ 'mermaid\nA["x"] -->|note (with parens)| B["y"]\n'
+ _FENCE
+ 'python\nprint("hi")\n'
+ _FENCE
)
html = _render_md(src)
assert 'class="language-mermaid"' not in html, f"mermaid fence misparing reintroduced: {html!r}"
assert 'class="language-python"' in html, f"python fence on its own should match: {html!r}"
def test_fence_closed_emits_code_block() -> None:
"""Baseline: a properly closed fence with its close on its own
line emits the <code> block as expected the anchor doesn't
break the normal case."""
src = "Intro\n" + _FENCE + "python\nimport os\n" + _FENCE + "\nAfter"
html = _render_md(src)
assert 'class="language-python"' in html
assert "import os" in html
def test_fence_close_at_end_of_buffer_emits() -> None:
"""A fence that closes at the very end of the buffer (no trailing
newline) still emits the anchor accepts end-of-string as a
valid line boundary, so the rehydration / static-render path
where the buffer ends cleanly at ``` still works."""
src = "Intro\n" + _FENCE + "python\nimport os\n" + _FENCE
html = _render_md(src)
assert 'class="language-python"' in html
assert "import os" in html
def test_fence_close_with_trailing_whitespace_emits() -> None:
"""A close followed only by spaces / tabs before \\n still counts
CommonMark allows trailing whitespace on the close line."""
src = "Intro\n" + _FENCE + "python\nimport os\n" + _FENCE + " \nAfter"
html = _render_md(src)
assert 'class="language-python"' in html
# ---------------------------------------------------------------------------
# postRenderHljs — progressive syntax highlighting + source-keyed cache
# ---------------------------------------------------------------------------
def _build_hljs_container_js(blocks: list[tuple[str, str]]) -> str:
"""Build a container with <pre><code class="language-LANG"> blocks.
``blocks`` is a list of ``(language, source)`` tuples the language
becomes the ``language-X`` class, the source becomes textContent."""
arr = "[" + ", ".join(f"[{json.dumps(lang)}, {json.dumps(src)}]" for lang, src in blocks) + "]"
return f"""
function buildHljsContainer(blocks) {{
const container = document.createElement('div');
for (const [lang, src] of blocks) {{
const pre = document.createElement('pre');
const code = document.createElement('code');
code.classList.add('language-' + lang);
code.textContent = src;
pre.appendChild(code);
container.appendChild(pre);
}}
return container;
}}
const blocks = {arr};
const container = buildHljsContainer(blocks);
"""
def test_hljs_cache_hit_skips_highlight_call() -> None:
"""Two postRenderHljs calls on identical source must invoke
hljs.highlightElement exactly once the second call hits the
cache and applies the stored markup synchronously. Mirrors the
mermaid SVG-cache invariant that lets streamingRender fire on
every rAF tick without re-tokenizing every code block."""
scenario = (
_build_hljs_container_js([("python", "import os")])
+ """
postRenderHljs(container);
const container2 = buildHljsContainer(blocks);
postRenderHljs(container2);
process.stdout.write(JSON.stringify({
highlightCalls: hljsHighlightCallCount,
cacheSize: _hljsCache.size,
firstHtml: container.children[0].children[0]._innerHTML,
secondHtml: container2.children[0].children[0]._innerHTML,
secondHasHljsClass: container2.children[0].children[0]._classes.has('hljs'),
}));
"""
)
out = _run_mermaid_scenario(scenario)
assert out["highlightCalls"] == 1, (
"second postRenderHljs call invoked highlightElement — cache miss"
)
assert out["cacheSize"] == 1
assert out["firstHtml"] == out["secondHtml"]
assert out["secondHasHljsClass"] is True
def test_hljs_distinct_sources_highlight_independently() -> None:
"""Distinct sources each trigger one highlight and cache one entry.
Cache key includes the source string, not e.g. just the language."""
scenario = (
_build_hljs_container_js([("python", "import os"), ("python", "print('hi')")])
+ """
postRenderHljs(container);
process.stdout.write(JSON.stringify({
highlightCalls: hljsHighlightCallCount,
cacheSize: _hljsCache.size,
}));
"""
)
out = _run_mermaid_scenario(scenario)
assert out["highlightCalls"] == 2
assert out["cacheSize"] == 2
def test_hljs_cache_separates_by_language() -> None:
"""Same source text under different language fences must NOT
collide in the cache language is part of the key. Otherwise a
`python` block of `foo` and a `ruby` block of `foo` would share
a single (wrongly-highlighted) cache entry."""
scenario = (
_build_hljs_container_js([("python", "foo"), ("ruby", "foo")])
+ """
postRenderHljs(container);
process.stdout.write(JSON.stringify({
highlightCalls: hljsHighlightCallCount,
cacheSize: _hljsCache.size,
}));
"""
)
out = _run_mermaid_scenario(scenario)
assert out["highlightCalls"] == 2
assert out["cacheSize"] == 2
def test_hljs_skips_no_highlight_langs() -> None:
"""language-mermaid / language-text / language-plaintext etc. must
get the `nohighlight` class without invoking hljs.highlightElement.
Highlighting plaintext or mermaid source would be both wasteful
and ugly."""
scenario = (
_build_hljs_container_js(
[("mermaid", "graph TD\\nA-->B"), ("text", "plain"), ("plaintext", "p")]
)
+ """
postRenderHljs(container);
process.stdout.write(JSON.stringify({
highlightCalls: hljsHighlightCallCount,
cacheSize: _hljsCache.size,
mermaidNoHighlight: container.children[0].children[0]._classes.has('nohighlight'),
textNoHighlight: container.children[1].children[0]._classes.has('nohighlight'),
plaintextNoHighlight: container.children[2].children[0]._classes.has('nohighlight'),
}));
"""
)
out = _run_mermaid_scenario(scenario)
assert out["highlightCalls"] == 0
assert out["cacheSize"] == 0
assert out["mermaidNoHighlight"] is True
assert out["textNoHighlight"] is True
assert out["plaintextNoHighlight"] is True
def test_hljs_terminal_lang_marks_pre_for_terminal_styling() -> None:
"""Shell-family languages (bash / sh / zsh / console / terminal)
must add the `code-terminal` class to the parent <pre>, so the
stylesheet can give them the terminal look-and-feel."""
scenario = (
_build_hljs_container_js([("bash", "echo hi")])
+ """
postRenderHljs(container);
process.stdout.write(JSON.stringify({
highlightCalls: hljsHighlightCallCount,
preHasTerminalClass: container.children[0]._classes.has('code-terminal'),
}));
"""
)
out = _run_mermaid_scenario(scenario)
assert out["highlightCalls"] == 1
assert out["preHasTerminalClass"] is True
def test_hljs_cache_evicts_oldest_at_cap() -> None:
"""FIFO eviction at _HLJS_CACHE_MAX. Mirrors the mermaid cache —
prevents unbounded growth on long sessions with many distinct
code blocks."""
scenario = """
const cap = _HLJS_CACHE_MAX;
for (let i = 0; i < cap + 5; i++) {
_cacheFifoEntry(_hljsCache, 'key-' + i, 'val-' + i, cap);
}
process.stdout.write(JSON.stringify({
size: _hljsCache.size,
hasOldest: _hljsCache.has('key-0'),
hasNewest: _hljsCache.has('key-' + (cap + 4)),
}));
"""
out = _run_mermaid_scenario(scenario)
assert out["size"] == 64
assert out["hasOldest"] is False
assert out["hasNewest"] is True
def test_hljs_overwrite_does_not_evict() -> None:
"""Overwriting an existing key is an in-place update, not a new
insertion must not evict the oldest unrelated entry. Same
invariant as the mermaid cache."""
scenario = """
const cap = _HLJS_CACHE_MAX;
for (let i = 0; i < cap; i++) {
_cacheFifoEntry(_hljsCache, 'key-' + i, 'val-' + i, cap);
}
_cacheFifoEntry(_hljsCache, 'key-5', 'val-updated', cap);
process.stdout.write(JSON.stringify({
size: _hljsCache.size,
hasOldest: _hljsCache.has('key-0'),
updated: _hljsCache.get('key-5'),
}));
"""
out = _run_mermaid_scenario(scenario)
assert out["size"] == 64
assert out["hasOldest"] is True, "overwrite evicted oldest unnecessarily"
assert out["updated"] == "val-updated"
def test_post_render_markdown_invokes_hljs() -> None:
"""postRenderMarkdown is the public end-of-stream entry point and
must still run syntax highlighting after the postRenderHljs
refactor regression guard for the public API surface that
app.js / coordinator code already call."""
scenario = (
_build_hljs_container_js([("python", "import os")])
+ """
postRenderMarkdown(container);
process.stdout.write(JSON.stringify({
highlightCalls: hljsHighlightCallCount,
hasHljsClass: container.children[0].children[0]._classes.has('hljs'),
}));
"""
)
out = _run_mermaid_scenario(scenario)
assert out["highlightCalls"] == 1
assert out["hasHljsClass"] is True
def test_streaming_render_invokes_hljs() -> None:
"""_streamingRenderApply must call postRenderHljs so closed code
fences appear progressively (syntax-highlighted) during streaming,
not only at stream_end via streamingRenderFinalize. The cache
keeps the per-tick cost down to a synchronous lookup."""
body = _RENDERER_JS.read_text(encoding="utf-8")
start = body.index("function _streamingRenderApply")
hljs_call = body.find("postRenderHljs(el)", start, start + 4000)
assert hljs_call != -1, (
"_streamingRenderApply must call postRenderHljs for progressive "
"syntax highlighting during streaming"
)
+536 -3
View File
@@ -458,6 +458,213 @@ class TestPlanExec:
assert messages[0]["content"] == ChatSession._PLAN_IDENTITY
# ---------------------------------------------------------------------------
# Tests — _exec_task (optional skill substitutes the hardcoded identity)
# ---------------------------------------------------------------------------
class TestTaskExec:
"""Tests for _exec_task: optional skill= replaces the default persona,
but operating guidance (one-shot, tool-use over narration, no follow-ups)
is always preserved."""
@staticmethod
def _capture_exec_messages(session, item):
"""Run _exec_task with _run_agent patched; return system message text."""
captured: dict = {}
def fake_run_agent(messages, **kwargs):
captured["messages"] = list(messages)
return "done"
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
session._exec_task(item)
return captured["messages"][0]["content"]
def test_known_skill_renders_into_system_message(self, tmp_db) -> None:
"""Validated skill content (with template vars resolved) replaces
the default '# Task Agent' persona, but the operating guidance
(the numbered list) is preserved those are sub-agent semantics
that a persona should layer on top of, not replace.
Covers the full prepareexec round-trip so a future regression
in either half (skill not stored on the item, or exec ignoring it)
is caught."""
session = _make_session()
skill = {
"name": "research",
"content": "# Research Agent\nws={{ws_id}} model={{model}} node={{node_id}}",
}
with patch("turnstone.core.session.get_skill_by_name", return_value=skill):
item = session._prepare_task("c1", {"prompt": "investigate X", "skill": "research"})
# Item carries the minimized projection — name/content/risk_level
# only — not the raw prompt_templates row.
assert item["skill"] == {
"name": "research",
"content": skill["content"],
"risk_level": "",
}
assert item.get("needs_approval") is True
assert "skill: research" in item["header"]
sys_msg = self._capture_exec_messages(session, item)
# Skill persona rendered with template vars resolved
assert "# Research Agent" in sys_msg
assert f"ws={session._ws_id}" in sys_msg
assert f"model={session.model}" in sys_msg
# Default persona is gone — skill substitutes for it.
assert "# Task Agent" not in sys_msg
assert "autonomous task agent with full tool access" not in sys_msg
# Operating guidance survives regardless of skill.
assert ChatSession._TASK_OPERATING_GUIDANCE in sys_msg
def test_omitted_skill_uses_hardcoded_identity(self, tmp_db) -> None:
"""Regression guard: without skill=, the default '# Task Agent'
persona AND the operating guidance both appear verbatim.
Pins the no-skill path so the substitution branch can't
accidentally swallow the default case."""
session = _make_session()
item = session._prepare_task("c1", {"prompt": "do x"})
assert item["skill"] is None
assert "skill:" not in item["header"]
sys_msg = self._capture_exec_messages(session, item)
assert ChatSession._TASK_DEFAULT_IDENTITY in sys_msg
assert ChatSession._TASK_OPERATING_GUIDANCE in sys_msg
# Default-persona literals also present (sanity check on the constant).
assert "# Task Agent" in sys_msg
assert "autonomous task agent with full tool access" in sys_msg
@pytest.mark.parametrize("skill_value", ["", " ", "\t\n"])
def test_prepare_task_empty_or_whitespace_skill_treated_as_omitted(
self, tmp_db, skill_value
) -> None:
"""Documented contract: ``skill=""`` (and whitespace-only) behaves
identically to omitting the skill arg. LLMs sometimes echo empty
strings rather than omit the field; this pins the documented
behavior so a future refactor of the ``(args.get("skill") or "").strip()``
chokepoint can't quietly diverge."""
session = _make_session()
item = session._prepare_task("c1", {"prompt": "do x", "skill": skill_value})
assert item.get("needs_approval") is True
assert item["skill"] is None
assert "skill:" not in item["header"]
def test_prepare_task_unknown_skill_returns_error(self, tmp_db) -> None:
"""Unknown skill name → clean error item, no approval needed.
Skill validation lives in _prepare_task so an LLM passing a
bogus name fails fast at approval time rather than at exec."""
session = _make_session()
with patch("turnstone.core.session.get_skill_by_name", return_value=None):
item = session._prepare_task("c1", {"prompt": "do x", "skill": "ghost"})
assert item.get("needs_approval") is False
assert "unknown skill 'ghost'" in item["error"]
assert "skill(action='search')" in item["error"]
def test_prepare_task_disabled_skill_returns_error(self, tmp_db) -> None:
"""Disabled skill → distinct error, mirrors the enabled gate that
``_exec_skill(action='load')`` (session.py:8404) and skill-search
already apply. Distinct from the unknown-skill phrasing so the
LLM's recovery path can tell 'not found' from 'quarantined'."""
session = _make_session()
disabled_skill = {
"name": "retired",
"content": "# Retired",
"enabled": False,
}
with patch("turnstone.core.session.get_skill_by_name", return_value=disabled_skill):
item = session._prepare_task("c1", {"prompt": "do x", "skill": "retired"})
assert item.get("needs_approval") is False
assert "is disabled" in item["error"]
# Distinct wording from the unknown-skill error, so the LLM can
# tell them apart at recovery time.
assert "unknown skill" not in item["error"]
def test_prepare_task_high_risk_skill_surfaces_in_header(self, tmp_db, caplog) -> None:
"""High/critical risk skills surface the tier in the approval header
and emit a structured warning, mirroring the signal ``_load_skills``
emits for session-level skills (session.py:1336)."""
import logging
session = _make_session()
risky_skill = {
"name": "danger",
"content": "# Danger",
"enabled": True,
"risk_level": "critical",
}
with (
caplog.at_level(logging.WARNING, logger="turnstone.core.session"),
patch("turnstone.core.session.get_skill_by_name", return_value=risky_skill),
):
item = session._prepare_task("c1", {"prompt": "do x", "skill": "danger"})
assert item.get("needs_approval") is True
assert "skill: danger" in item["header"]
assert "risk: critical" in item["header"]
warning_seen = any("high_risk_skill" in r.getMessage() for r in caplog.records)
assert warning_seen, "expected task_agent.high_risk_skill warning"
def test_prepare_task_normal_risk_skill_omits_tier_from_header(self, tmp_db) -> None:
"""Header only surfaces high/critical — low/medium/safe skills don't
pollute the approval line."""
session = _make_session()
ok_skill = {
"name": "research",
"content": "# Research",
"enabled": True,
"risk_level": "low",
}
with patch("turnstone.core.session.get_skill_by_name", return_value=ok_skill):
item = session._prepare_task("c1", {"prompt": "do x", "skill": "research"})
assert "skill: research" in item["header"]
assert "risk:" not in item["header"]
def test_evaluate_intent_projects_skill_for_task_agent(self, tmp_db, monkeypatch) -> None:
"""Judge projection includes the skill name so heuristic arg_patterns
can match on it and the audit row records which persona was chosen.
Mirrors the long-standing ``spawn_workstream`` projection at
session.py:4603 without it, policy rules targeting risky
skills via ``task_agent`` silently no-op."""
session = _make_session()
fake_verdict = MagicMock()
fake_verdict.to_dict.return_value = {"verdict_id": "v0", "tier": "heuristic"}
fake_judge = MagicMock()
fake_judge.evaluate.side_effect = lambda items, *_a, **_kw: [fake_verdict] * len(items)
monkeypatch.setattr(session, "_ensure_judge", lambda: fake_judge)
skill = {"name": "research", "content": "# Research", "enabled": True}
with patch("turnstone.core.session.get_skill_by_name", return_value=skill):
item = session._prepare_task("c1", {"prompt": "investigate X", "skill": "research"})
session._evaluate_intent([item])
fa = item["func_args"]
assert fa["skill"] == "research"
assert fa["prompt"] == "investigate X"
def test_evaluate_intent_projects_empty_skill_when_omitted(self, tmp_db, monkeypatch) -> None:
"""Symmetric regression guard: no-skill case projects skill="" so
the func_args shape is stable across both branches (the judge can
always read ``func_args["skill"]`` without a KeyError)."""
session = _make_session()
fake_verdict = MagicMock()
fake_verdict.to_dict.return_value = {"verdict_id": "v0", "tier": "heuristic"}
fake_judge = MagicMock()
fake_judge.evaluate.side_effect = lambda items, *_a, **_kw: [fake_verdict] * len(items)
monkeypatch.setattr(session, "_ensure_judge", lambda: fake_judge)
item = session._prepare_task("c1", {"prompt": "do x"})
session._evaluate_intent([item])
fa = item["func_args"]
assert fa["skill"] == ""
assert fa["prompt"] == "do x"
# ---------------------------------------------------------------------------
# Per-call model override on plan_agent / task_agent
# ---------------------------------------------------------------------------
@@ -504,9 +711,51 @@ class TestAgentModelOverride:
assert item.get("needs_approval") is False
assert "error" in item
assert "unknown model alias 'bogus'" in item["error"]
# The error guidance must list the available aliases so the LLM can retry.
for alias in ("default", "smart", "fast"):
# Error guidance lists the aliases the LLM may retry, intentionally
# excluding ``default`` — that alias is operator-only (see
# ``test_prepare_plan_default_model_rejected``). Surfacing it here
# would re-enable the per-role-override bypass even though the
# tool description hides it.
for alias in ("smart", "fast"):
assert alias in item["error"]
assert "default" not in item["error"]
def test_prepare_plan_default_model_rejected(self, tmp_db) -> None:
"""``model="default"`` is rejected even when the alias exists in
the registry bypasses the operator-configured ``plan_alias``."""
session = _make_session(registry=self._registry(), model_alias="default")
item = session._prepare_plan("c1", {"goal": "do x", "model": "default"})
assert item.get("needs_approval") is False
assert "error" in item
assert "'default' is not a selectable model alias" in item["error"]
assert "Omit `model=`" in item["error"]
def test_prepare_plan_default_model_rejected_with_whitespace(self, tmp_db) -> None:
"""The ``default`` rejection runs after ``strip()`` so leading/
trailing whitespace can't sneak the alias past the carve-out."""
session = _make_session(registry=self._registry(), model_alias="default")
item = session._prepare_plan("c1", {"goal": "do x", "model": " default "})
assert item.get("needs_approval") is False
assert "'default' is not a selectable model alias" in item["error"]
def test_prepare_plan_unknown_model_with_only_default_in_registry(self, tmp_db) -> None:
"""When the registry holds only the reserved ``default`` alias
(single-CLI-model back-compat), the unknown-alias error must say
'(no alternative aliases configured — omit `model=`)' not the
misleading '(no registry configured)' that suggests routing isn't
wired up at all."""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
reg = ModelRegistry(
models={"default": ModelConfig("default", "x", "x", "m")},
default="default",
)
session = _make_session(registry=reg, model_alias="default")
item = session._prepare_plan("c1", {"goal": "do x", "model": "bogus"})
assert item.get("needs_approval") is False
assert "unknown model alias 'bogus'" in item["error"]
assert "no alternative aliases configured" in item["error"]
assert "no registry configured" not in item["error"]
# ---- _prepare_task ----
@@ -526,6 +775,15 @@ class TestAgentModelOverride:
assert item.get("needs_approval") is False
assert "error" in item
assert "unknown model alias 'bogus'" in item["error"]
assert "default" not in item["error"]
def test_prepare_task_default_model_rejected(self, tmp_db) -> None:
"""Symmetric carve-out for task_agent — see
``test_prepare_plan_default_model_rejected``."""
session = _make_session(registry=self._registry(), model_alias="default")
item = session._prepare_task("c1", {"prompt": "do x", "model": "default"})
assert item.get("needs_approval") is False
assert "'default' is not a selectable model alias" in item["error"]
# ---- tool description rendering ----
@@ -544,8 +802,11 @@ class TestAgentModelOverride:
tool = self._agent_tool(session, name)
assert tool is not None, f"{name} missing from session tools"
desc = tool["function"]["parameters"]["properties"]["model"]["description"]
for alias in ("default", "smart", "fast"):
for alias in ("smart", "fast"):
assert f"`{alias}`" in desc, f"alias {alias} missing from {desc!r}"
# ``default`` is intentionally hidden — see
# ``test_render_omits_default_alias_from_description``.
assert "`default`" not in desc
def test_render_no_op_without_registry(self, tmp_db) -> None:
"""No registry → leave the placeholder description untouched."""
@@ -576,6 +837,82 @@ class TestAgentModelOverride:
desc = plan_tool["function"]["parameters"]["properties"]["model"]["description"]
assert "`bigboi`" in desc
def test_render_omits_default_alias_from_description(self, tmp_db) -> None:
"""The ``default`` alias is filtered from the LLM-facing alias list.
Reading "default" as English ("use the default") and passing it
explicitly bypasses the operator-configured per-role plan_alias /
task_alias. The LLM should reach the per-role default by omitting
``model=`` instead.
"""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
reg = ModelRegistry(
models={
"default": ModelConfig("default", "x", "x", "m"),
"gh200": ModelConfig("gh200", "x", "x", "m"),
"opus-4.7": ModelConfig("opus-4.7", "x", "x", "m"),
},
default="default",
)
session = _make_session(registry=reg, model_alias="default")
for name in ("plan_agent", "task_agent"):
tool = self._agent_tool(session, name)
assert tool is not None
desc = tool["function"]["parameters"]["properties"]["model"]["description"]
assert "`gh200`" in desc
assert "`opus-4.7`" in desc
assert "`default`" not in desc
def test_render_falls_back_to_base_when_only_default_alias(self, tmp_db) -> None:
"""Single-CLI-model registries (only ``default`` in registry) leave
the base description untouched the LLM sees ``"No alternative
aliases configured"`` rather than an empty alias list."""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
reg = ModelRegistry(
models={"default": ModelConfig("default", "x", "x", "m")},
default="default",
)
session = _make_session(registry=reg, model_alias="default")
plan_tool = self._agent_tool(session, "plan_agent")
assert plan_tool is not None
desc = plan_tool["function"]["parameters"]["properties"]["model"]["description"]
assert "No alternative aliases configured" in desc
def test_refresh_into_only_default_resets_to_base(self, tmp_db) -> None:
"""A reload that drops the registry to only ``default`` must clear
stale alias names from the previously-rendered tool descriptions
not return early and leave them in place."""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
reg = ModelRegistry(
models={
"default": ModelConfig("default", "x", "x", "m"),
"smart": ModelConfig("smart", "x", "x", "m"),
"fast": ModelConfig("fast", "x", "x", "m"),
},
default="default",
)
session = _make_session(registry=reg, model_alias="default")
# Sanity: initial render carries the non-default aliases.
plan_tool = self._agent_tool(session, "plan_agent")
assert plan_tool is not None
desc = plan_tool["function"]["parameters"]["properties"]["model"]["description"]
assert "`smart`" in desc and "`fast`" in desc
# Reload the registry down to only ``default`` (admin removed
# every other model definition).
reg.reload({"default": ModelConfig("default", "x", "x", "m")}, "default")
session.refresh_agent_tool_schemas()
plan_tool = self._agent_tool(session, "plan_agent")
assert plan_tool is not None
desc = plan_tool["function"]["parameters"]["properties"]["model"]["description"]
assert "`smart`" not in desc, f"stale alias survived reload: {desc!r}"
assert "`fast`" not in desc, f"stale alias survived reload: {desc!r}"
assert "No alternative aliases configured" in desc
def test_module_level_constants_not_mutated(self, tmp_db) -> None:
"""Rendering must not pollute the module-level TOOLS list shared
across all sessions."""
@@ -1974,6 +2311,202 @@ class TestCoordinatorMemoryScope:
assert scopes == ["workstream", "user", "global"]
class TestMemoryToolAudit:
"""Mutating memory tool actions emit audit rows.
Closes the gap that masked the May 2026 vllm_fork_overlay_pattern
investigation: only the admin-console DELETE route emitted
``memory.delete``, so a long-running session whose memory was
deleted via the admin UI couldn't tell from logs alone whether the
row had been deleted out-of-band, never persisted, or was never
visible. Read actions (get/search/list) intentionally stay
un-audited auditing reads would multiply audit volume without
forensic value.
"""
@staticmethod
def _audit_rows(action: str) -> list[dict]:
from turnstone.core.storage._registry import get_storage
return get_storage().list_audit_events(action=action)
def test_save_new_emits_memory_save(self, tmp_db):
session = _make_session(ws_id="ws-1", user_id="user-1")
item = session._prepare_memory(
"call_1",
{
"action": "save",
"name": "fact_one",
"content": "alpha content",
"scope": "user",
"type": "reference",
},
)
assert "error" not in item
session._exec_memory(item)
rows = self._audit_rows("memory.save")
assert len(rows) == 1
row = rows[0]
assert row["user_id"] == "user-1"
assert row["resource_type"] == "memory"
assert row["resource_id"] # memory_id was populated
detail = json.loads(row["detail"])
assert detail["name"] == "fact_one"
assert detail["scope"] == "user"
assert detail["scope_id"] == "user-1"
assert detail["type"] == "reference"
assert detail["ws_id"] == "ws-1"
# The "create" path must NOT also stamp an update row.
assert self._audit_rows("memory.update") == []
def test_save_global_scope_emits_empty_scope_id(self, tmp_db):
"""Global memories have no scope_id — the audit row's detail
must still carry the key (with value ``""``) so a forensic
consumer can distinguish ``scope='global'`` from a row that
forgot to populate ``scope_id`` for a scoped write."""
session = _make_session(ws_id="ws-1", user_id="user-1")
item = session._prepare_memory(
"call_1",
{
"action": "save",
"name": "fact_global",
"content": "shared content",
"scope": "global",
},
)
assert "error" not in item
session._exec_memory(item)
rows = self._audit_rows("memory.save")
assert len(rows) == 1
detail = json.loads(rows[0]["detail"])
assert detail["scope"] == "global"
assert detail["scope_id"] == ""
assert detail["ws_id"] == "ws-1"
def test_save_upsert_emits_memory_update(self, tmp_db):
session = _make_session(ws_id="ws-1", user_id="user-1")
for content in ("first", "second"):
item = session._prepare_memory(
"call_x",
{
"action": "save",
"name": "fact_one",
"content": content,
"scope": "user",
"type": "reference",
},
)
session._exec_memory(item)
saves = self._audit_rows("memory.save")
updates = self._audit_rows("memory.update")
assert len(saves) == 1
assert len(updates) == 1
# Same memory_id on both rows — the update audits the row save created.
assert saves[0]["resource_id"] == updates[0]["resource_id"]
def test_delete_emits_memory_delete(self, tmp_db):
session = _make_session(ws_id="ws-1", user_id="user-1")
save_item = session._prepare_memory(
"call_1",
{
"action": "save",
"name": "fact_one",
"content": "alpha",
"scope": "user",
"type": "reference",
},
)
session._exec_memory(save_item)
saved_memory_id = self._audit_rows("memory.save")[0]["resource_id"]
delete_item = session._prepare_memory(
"call_2",
{"action": "delete", "name": "fact_one", "scope": "user"},
)
_, msg = session._exec_memory(delete_item)
assert "Deleted memory" in msg
rows = self._audit_rows("memory.delete")
assert len(rows) == 1
# resource_id must point at the same row save audited — proves
# delete-by-name resolved to the right row before recording.
assert rows[0]["resource_id"] == saved_memory_id
detail = json.loads(rows[0]["detail"])
assert detail["name"] == "fact_one"
assert detail["scope"] == "user"
assert detail["type"] == "reference"
def test_delete_not_found_emits_no_audit(self, tmp_db):
session = _make_session(ws_id="ws-1", user_id="user-1")
delete_item = session._prepare_memory(
"call_1",
{"action": "delete", "name": "no_such_mem", "scope": "user"},
)
_, msg = session._exec_memory(delete_item)
assert "not found" in msg
assert self._audit_rows("memory.delete") == []
def test_reads_emit_no_audit(self, tmp_db):
session = _make_session(ws_id="ws-1", user_id="user-1")
session._exec_memory(
session._prepare_memory(
"call_save",
{
"action": "save",
"name": "fact_one",
"content": "alpha",
"scope": "user",
},
)
)
for spec in (
{"action": "get", "name": "fact_one", "scope": "user"},
{"action": "search", "query": "fact"},
{"action": "list"},
):
item = session._prepare_memory("call_read", spec)
assert "error" not in item
session._exec_memory(item)
# Only the save above should have audited.
save_count = len(self._audit_rows("memory.save"))
update_count = len(self._audit_rows("memory.update"))
delete_count = len(self._audit_rows("memory.delete"))
assert (save_count, update_count, delete_count) == (1, 0, 0)
def test_audit_failure_does_not_break_tool_call(self, tmp_db):
"""A blow-up inside record_audit must not propagate to the LLM.
Auditing is best-effort instrumentation; a storage hiccup that
prevents the audit row from landing must not also lose the
save/delete the user actually asked for.
"""
session = _make_session(ws_id="ws-1", user_id="user-1")
item = session._prepare_memory(
"call_1",
{
"action": "save",
"name": "fact_one",
"content": "alpha",
"scope": "user",
},
)
with patch(
"turnstone.core.audit.record_audit",
side_effect=RuntimeError("audit storage exploded"),
):
_, msg = session._exec_memory(item)
assert "Saved memory 'fact_one'" in msg
# The save itself still landed.
from turnstone.core.memory import get_structured_memory_by_name
assert get_structured_memory_by_name("fact_one", "user", "user-1") is not None
class TestPerKindToolVariants:
"""Verify the ``kind_variants`` metadata applies per-kind tool overrides.
+424
View File
@@ -0,0 +1,424 @@
"""Session-level integration tests for Phase 5 (Chat Completions
``reasoning`` field replay against vLLM).
Phase 5 is the only reasoning-replay path that does NOT use the static
``supports_reasoning_replay`` capability gate. It's a parallel path to
Paths 1+2, gated entirely at the session level on three conditions:
1. Provider is ``OpenAIChatCompletionsProvider``.
2. ``server_compat.server_type == "vllm"``.
3. Operator-set ``ModelConfig.replay_reasoning_to_model`` is True.
These tests drive through ``ChatSession._maybe_attach_vllm_chat_reasoning``
to pin each gate independently, then one round-trip test through the real
OpenAI Python SDK + httpx MockTransport confirms the ``reasoning`` field
actually reaches the wire bytes (the SDK-boundary guarantee that the
session-level attach approach hinges on).
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
import httpx
import pytest
from tests._session_helpers import make_session as _make_session
from turnstone.core.providers._anthropic import AnthropicProvider
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
def _vllm_registry(*, replay: bool = True, alias: str = "qwen3") -> Any:
"""Stub registry with a vLLM-typed server_compat profile and the
Phase 5 operator flag toggleable.
Mirrors production ModelConfig shape: ``server_compat`` lives at
the top-level dataclass field, NOT inside ``capabilities``. Both
model_registry loader paths (DB row at line 401, config.toml at
line 485) ``caps.pop("server_compat", {})`` and hoist it up, so a
stub that populates ``capabilities["server_compat"]`` would mask
the same bug Phase 5 stepped on initially.
"""
cfg = SimpleNamespace(
replay_reasoning_to_model=replay,
capabilities={},
server_compat={"server_type": "vllm"},
)
return SimpleNamespace(
get_config=lambda a: cfg if a == alias else (_ for _ in ()).throw(KeyError(a)),
)
def _registry_with_server_type(server_type: str, *, replay: bool = True) -> Any:
cfg = SimpleNamespace(
replay_reasoning_to_model=replay,
capabilities={},
server_compat={"server_type": server_type},
)
return SimpleNamespace(
get_config=lambda _alias: cfg,
)
def _assistant_msg_with_thinking(text: str = "let me think") -> dict[str, Any]:
"""Anthropic-shape persisted reasoning — the cross-provider case
where workstream started on Anthropic and operator flipped to
vLLM-served Qwen3. Helper must extract the text and discard the
Anthropic signature."""
return {
"role": "assistant",
"content": "Final answer.",
"_provider_content": [
{"type": "thinking", "thinking": text, "signature": "sig"},
{"type": "text", "text": "Final answer."},
],
}
# ---------------------------------------------------------------------------
# Gate tests via ``_maybe_attach_vllm_chat_reasoning`` directly
# ---------------------------------------------------------------------------
class TestMaybeAttachVllmChatReasoningGates:
"""The session-level method that combines all three Phase 5 gates."""
def test_all_gates_pass_attaches_reasoning(self) -> None:
session = _make_session()
session._registry = _vllm_registry(replay=True)
session._model_alias = "qwen3"
provider = OpenAIChatCompletionsProvider()
msgs = [{"role": "user", "content": "q"}, _assistant_msg_with_thinking("CoT")]
out = session._maybe_attach_vllm_chat_reasoning(msgs, provider)
assert out[1]["reasoning"] == "CoT"
def test_non_chat_completions_provider_is_no_op(self) -> None:
# Provider isinstance gate: Anthropic / Responses / Google all
# have their own reasoning-replay paths (Paths 1 / 2) — Phase 5
# must not double-attach.
session = _make_session()
session._registry = _vllm_registry(replay=True)
session._model_alias = "qwen3"
provider = AnthropicProvider()
msgs = [_assistant_msg_with_thinking()]
out = session._maybe_attach_vllm_chat_reasoning(msgs, provider)
assert "reasoning" not in out[0]
# Same reference — no copy made.
assert out[0] is msgs[0]
def test_openai_responses_provider_is_no_op(self) -> None:
# OpenAIResponsesProvider is a top-level class (not a subclass of
# OpenAIChatCompletionsProvider) — the isinstance gate rejects
# it cleanly. This is the load-bearing distinction; an
# accidental inheritance refactor would break the gate.
session = _make_session()
session._registry = _vllm_registry(replay=True)
session._model_alias = "qwen3"
provider = OpenAIResponsesProvider()
msgs = [_assistant_msg_with_thinking()]
out = session._maybe_attach_vllm_chat_reasoning(msgs, provider)
assert "reasoning" not in out[0]
@pytest.mark.parametrize("server_type", ["", "llama.cpp", "sglang", "openai", "unknown"])
def test_non_vllm_server_type_is_no_op(self, server_type: str) -> None:
# Server-type pin bounds blast radius — canonical OpenAI Chat
# Completions, llama.cpp, sglang, and any unrecognised server
# never receive the non-standard ``reasoning`` field.
session = _make_session()
session._registry = _registry_with_server_type(server_type, replay=True)
session._model_alias = "some-model"
provider = OpenAIChatCompletionsProvider()
msgs = [_assistant_msg_with_thinking()]
out = session._maybe_attach_vllm_chat_reasoning(msgs, provider)
assert "reasoning" not in out[0]
def test_operator_flag_off_is_no_op(self) -> None:
session = _make_session()
session._registry = _vllm_registry(replay=False) # operator flag OFF
session._model_alias = "qwen3"
provider = OpenAIChatCompletionsProvider()
msgs = [_assistant_msg_with_thinking()]
out = session._maybe_attach_vllm_chat_reasoning(msgs, provider)
assert "reasoning" not in out[0]
def test_missing_registry_is_no_op(self) -> None:
session = _make_session()
session._registry = None
session._model_alias = "qwen3"
provider = OpenAIChatCompletionsProvider()
msgs = [_assistant_msg_with_thinking()]
out = session._maybe_attach_vllm_chat_reasoning(msgs, provider)
assert "reasoning" not in out[0]
def test_missing_alias_is_no_op(self) -> None:
session = _make_session()
session._registry = _vllm_registry(replay=True)
session._model_alias = ""
provider = OpenAIChatCompletionsProvider()
msgs = [_assistant_msg_with_thinking()]
out = session._maybe_attach_vllm_chat_reasoning(msgs, provider)
assert "reasoning" not in out[0]
def test_registry_exception_is_no_op(self) -> None:
# Defensive: registry lookup raising must degrade to no-attach,
# not break the call. Conservative default — operator can
# always re-flip the flag once the registry is healthy.
def boom(_alias: str) -> Any:
raise KeyError("missing")
session = _make_session()
session._registry = SimpleNamespace(get_config=boom)
session._model_alias = "qwen3"
provider = OpenAIChatCompletionsProvider()
msgs = [_assistant_msg_with_thinking()]
out = session._maybe_attach_vllm_chat_reasoning(msgs, provider)
assert "reasoning" not in out[0]
def test_explicit_alias_arg_overrides_session_default(self) -> None:
# When _try_stream forwards an explicit ``model_alias`` (different
# from the session's primary), the helper must read THAT alias'
# config — not the session's primary. Mirrors the per-alias
# behaviour pinned for _resolve_replay_reasoning_to_model.
def per_alias(alias: str) -> Any:
return SimpleNamespace(
replay_reasoning_to_model=(alias == "wants-replay"),
capabilities={},
server_compat={"server_type": "vllm"},
)
session = _make_session()
session._registry = SimpleNamespace(get_config=per_alias)
session._model_alias = "primary"
provider = OpenAIChatCompletionsProvider()
msgs = [_assistant_msg_with_thinking()]
# Default alias → flag off → no attach.
out_default = session._maybe_attach_vllm_chat_reasoning(msgs, provider)
assert "reasoning" not in out_default[0]
# Explicit alias arg → flag on → attached.
out_explicit = session._maybe_attach_vllm_chat_reasoning(msgs, provider, "wants-replay")
assert out_explicit[0]["reasoning"] == "let me think"
# ---------------------------------------------------------------------------
# End-to-end: SDK passthrough is the load-bearing assumption. Verify it
# with a real OpenAI client wired against an httpx MockTransport that
# inspects the body (per feedback_mock_transport_body_inspection).
# ---------------------------------------------------------------------------
class TestReasoningFieldReachesWireBytes:
"""One round-trip test through the real OpenAI Python SDK confirms
the ``reasoning`` field on an assistant message dict survives the
sanitize_messages strip (only ``_``-prefixed keys are dropped) AND
the SDK's TypedDict input shape (no runtime field filtering)."""
def _capture_client(self) -> tuple[Any, list[dict[str, Any]]]:
from openai import OpenAI
captured: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
body = request.content.decode("utf-8") if request.content else ""
captured.append({"url": str(request.url), "body": body})
return httpx.Response(
200,
json={
"id": "chatcmpl-vllm-spike",
"object": "chat.completion",
"created": 0,
"model": "qwen3-test",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
client = OpenAI(
api_key="sk-test",
base_url="http://mock.local/v1",
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
return client, captured
def test_reasoning_field_present_in_wire_body_when_attached(self) -> None:
# Send messages that have the Phase 5 ``reasoning`` field
# attached. Drive a real provider call through the real OpenAI
# SDK + mock httpx and verify the field is in the captured POST
# body — the SDK passthrough assumption that the entire
# session-level approach hinges on.
client, captured = self._capture_client()
provider = OpenAIChatCompletionsProvider()
# Mimic the post-attach message shape that
# ``_maybe_attach_vllm_chat_reasoning`` produces, then sanitize.
# ``sanitize_messages`` runs inside provider._prepare_messages
# and must preserve the non-``_``-prefixed ``reasoning`` field.
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "Final answer.",
"reasoning": "vLLM-shaped CoT text",
"_provider_content": [{"type": "reasoning_text", "text": "vLLM-shaped CoT text"}],
},
{"role": "user", "content": "follow-up"},
]
provider.create_completion(
client=client,
model="qwen3-test",
messages=messages,
max_tokens=10,
temperature=0.5,
reasoning_effort="medium",
extra_params=None,
capabilities=provider.get_capabilities("qwen3-test"),
)
assert captured, "no request captured"
body = json.loads(captured[0]["body"])
assistant_msg = next(m for m in body["messages"] if m["role"] == "assistant")
# Wire-format guarantee: field survives sanitize_messages + SDK.
assert assistant_msg.get("reasoning") == "vLLM-shaped CoT text"
# And the ``_``-prefixed sibling is stripped by sanitize_messages.
assert "_provider_content" not in assistant_msg
def test_reasoning_field_absent_when_not_attached(self) -> None:
# Negative case: when the session-level gate decided NOT to
# attach (any of the 3 gates failed), the SDK round-trip carries
# no ``reasoning`` field — the operator's opt-out / non-vLLM
# destination is honoured all the way to the wire.
client, captured = self._capture_client()
provider = OpenAIChatCompletionsProvider()
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "Final answer.",
# No ``reasoning`` field — pre-attach shape, gate said no.
"_provider_content": [{"type": "reasoning_text", "text": "would-have-replayed"}],
},
{"role": "user", "content": "follow-up"},
]
provider.create_completion(
client=client,
model="gpt-4o", # canonical OpenAI, not vLLM
messages=messages,
max_tokens=10,
temperature=0.5,
reasoning_effort="medium",
extra_params=None,
capabilities=provider.get_capabilities("gpt-4o"),
)
body = json.loads(captured[0]["body"])
assistant_msg = next(m for m in body["messages"] if m["role"] == "assistant")
assert "reasoning" not in assistant_msg
assert "_provider_content" not in assistant_msg
# ---------------------------------------------------------------------------
# Call-site integration: confirm _try_stream and _utility_completion both
# invoke the helper. Pins that the 2 hoist points stay in sync; a missed
# call site is exactly the kind of regression this catches. The agent
# _run_agent path is deliberately NOT a Phase 5 hoist — see the NOTE
# comment inside _run_agent's nested _api_call closure (grep session.py
# for "Phase 5 vLLM ``reasoning`` field replay is intentionally NOT
# wired here"): agent assistant messages don't carry
# ``_provider_content`` so the helper would no-op every turn anyway.
# ---------------------------------------------------------------------------
class TestCallSitesInvokeMaybeAttach:
"""The helper does nothing unless one of the 2 call sites calls it.
Verify the wiring at each without this, a refactor that drops a
call site would silently regress Phase 5 on that path."""
def test_try_stream_call_site_attaches(self) -> None:
session = _make_session()
session._registry = _vllm_registry(replay=True)
session._model_alias = "qwen3"
captured: dict[str, Any] = {}
def capture_streaming(**kwargs: Any) -> Any:
captured.update(kwargs)
return iter([])
provider = OpenAIChatCompletionsProvider()
# Patch only the network-facing method so we don't actually call
# an LLM, but keep the real provider instance (so the isinstance
# gate sees the right type).
provider.create_streaming = capture_streaming # type: ignore[method-assign]
with (
patch.object(session, "_get_active_tools", return_value=None),
patch.object(session, "_provider_extra_params", return_value=None),
patch.object(session, "_get_deferred_names", return_value=frozenset()),
patch.object(session, "_check_cancelled"),
):
session._try_stream(
client=MagicMock(),
model="qwen3",
msgs=[_assistant_msg_with_thinking("from try_stream")],
provider=provider,
model_alias="qwen3",
)
# The messages handed to the provider include the attached
# reasoning field — proves _try_stream invoked
# _maybe_attach_vllm_chat_reasoning before the call.
msgs_sent = captured["messages"]
assert msgs_sent[0]["reasoning"] == "from try_stream"
def test_utility_completion_call_site_attaches(self) -> None:
session = _make_session()
session._registry = _vllm_registry(replay=True)
session._model_alias = "qwen3"
captured: dict[str, Any] = {}
def capture_completion(**kwargs: Any) -> Any:
captured.update(kwargs)
return SimpleNamespace(
content="", tool_calls=[], usage=None, raw_blocks=None, provider_blocks=None
)
provider = OpenAIChatCompletionsProvider()
provider.create_completion = capture_completion # type: ignore[method-assign]
session._provider = provider
with (
patch.object(session, "_provider_extra_params", return_value=None),
patch.object(
session, "_get_capabilities", return_value=provider.get_capabilities("qwen3")
),
):
session._utility_completion(
messages=[_assistant_msg_with_thinking("from utility")],
)
msgs_sent = captured["messages"]
assert msgs_sent[0]["reasoning"] == "from utility"
+12 -4
View File
@@ -73,7 +73,8 @@ class TestMaybeSynthReasoningBlock:
session = _make_session()
session._registry = SimpleNamespace(
get_config=lambda alias: SimpleNamespace(
capabilities={"server_compat": {"server_type": "vllm"}},
capabilities={},
server_compat={"server_type": "vllm"},
)
)
session._model_alias = "qwen3-32b"
@@ -296,7 +297,8 @@ class TestStreamResponseSynthBlockIntegration:
session = _make_session()
session._registry = SimpleNamespace(
get_config=lambda alias: SimpleNamespace(
capabilities={"server_compat": {"server_type": "vllm"}},
capabilities={},
server_compat={"server_type": "vllm"},
)
)
session._model_alias = "qwen3-32b"
@@ -319,16 +321,21 @@ class TestResolveServerType:
def test_returns_empty_when_no_alias(self) -> None:
session = _make_session()
session._registry = SimpleNamespace(
get_config=lambda alias: SimpleNamespace(capabilities={})
get_config=lambda alias: SimpleNamespace(capabilities={}, server_compat={})
)
session._model_alias = ""
assert session._resolve_server_type() == ""
def test_returns_server_type_when_present(self) -> None:
# Mirrors production ModelConfig shape: server_compat lives at
# the top-level dataclass field, NOT inside capabilities. Both
# model_registry loader paths pop("server_compat") out of caps
# before construction (see model_registry.py:401, 485).
session = _make_session()
session._registry = SimpleNamespace(
get_config=lambda alias: SimpleNamespace(
capabilities={"server_compat": {"server_type": "llama.cpp"}}
capabilities={},
server_compat={"server_type": "llama.cpp"},
)
)
session._model_alias = "local-model"
@@ -339,6 +346,7 @@ class TestResolveServerType:
session._registry = SimpleNamespace(
get_config=lambda alias: SimpleNamespace(
capabilities={"context_window": 32768},
server_compat={},
)
)
session._model_alias = "local-model"
+188 -2
View File
@@ -167,8 +167,8 @@ def test_on_intent_verdict_persists_verdict_row() -> None:
}
with _patch_get_storage(storage):
ui.on_intent_verdict(verdict)
storage.create_intent_verdict.assert_called_once()
kwargs = storage.create_intent_verdict.call_args.kwargs
storage.upsert_intent_verdict.assert_called_once()
kwargs = storage.upsert_intent_verdict.call_args.kwargs
assert kwargs["verdict_id"] == "v1"
assert kwargs["ws_id"] == "ws-1"
assert kwargs["call_id"] == "c1"
@@ -352,6 +352,192 @@ def test_resolve_approval_stamps_all_pending_verdicts() -> None:
assert ui._last_verdict_decision == "denied"
# ---------------------------------------------------------------------------
# user_decision value space — pending / approved / denied / timeout
# / auto-approve reasons (policy / blanket / skill / always / auto_approve_tools).
# Guards the "user_decision is never empty for new rows" invariant.
# ---------------------------------------------------------------------------
def test_resolve_approval_timeout_kwarg_writes_timeout_value() -> None:
"""``resolve_approval(False, ..., timeout=True)`` writes
``user_decision="timeout"`` so the audit trail can distinguish a
passive timeout expiry from an active user denial the feedback
string used to carry this distinction but the column alone could not."""
storage = MagicMock()
ui = _make_ui()
with _patch_get_storage(storage):
ui.on_intent_verdict({"verdict_id": "v1", "call_id": "c1"})
with _patch_get_storage(storage):
ui.resolve_approval(False, "expired", timeout=True)
storage.update_intent_verdict.assert_any_call("v1", user_decision="timeout")
assert ui._last_verdict_decision == "timeout"
def test_resolve_approval_timeout_with_approved_raises() -> None:
"""``timeout=True`` is mutually exclusive with ``approved=True`` —
the combination would land a row whose audit column says
``"timeout"`` while the SSE event reports ``approved=True``. Fail
loud so the inconsistency can't ship silently."""
import pytest
ui = _make_ui()
with pytest.raises(ValueError, match="timeout"):
ui.resolve_approval(True, timeout=True)
def test_record_auto_approves_populates_reason_lookup() -> None:
"""``_record_auto_approves`` must seed
``_auto_approve_reasons[call_id]`` with the per-item reason so a
late-arriving LLM judge verdict can recover the auto-approve
reason via ``on_intent_verdict``."""
storage = MagicMock()
ui = _make_ui()
items = [
{
"call_id": "c-policy",
"func_name": "bash",
"auto_approved": True,
"auto_approve_reason": "policy",
},
{
"call_id": "c-blanket",
"func_name": "list_workstreams",
"auto_approved": True,
"auto_approve_reason": "blanket",
},
]
with _patch_get_storage(storage):
ui._record_auto_approves(items)
assert "c-policy" in ui._auto_approve_reasons
assert "c-blanket" in ui._auto_approve_reasons
assert ui._auto_approve_reasons["c-policy"][0] == "policy"
assert ui._auto_approve_reasons["c-blanket"][0] == "blanket"
def test_on_intent_verdict_consumes_auto_approve_reason() -> None:
"""A late LLM verdict for a previously auto-approved call_id picks
up the reason from ``_auto_approve_reasons``, stamps it on the
verdict before persist, and pops the entry so re-use isn't
possible. Closes the misdiagnosis bug where auto-approved tools
landed verdict rows with ``user_decision=""``."""
storage = MagicMock()
ui = _make_ui()
ui._auto_approve_reasons["c-x"] = ("auto_approve_tools", 0.0)
with _patch_get_storage(storage):
ui.on_intent_verdict({"verdict_id": "v-x", "call_id": "c-x"})
storage.upsert_intent_verdict.assert_called_once()
kwargs = storage.upsert_intent_verdict.call_args.kwargs
assert kwargs["user_decision"] == "auto_approve_tools"
# Consumed on read so the same call_id can't double-stamp later.
assert "c-x" not in ui._auto_approve_reasons
# Auto-stamped verdicts must NOT join _pending_verdicts — the
# row's final decision is already set; appending would let a
# later resolve_approval overwrite the auto-reason with the
# manual decision (real audit-trail clobber bug).
assert ui._pending_verdicts == []
def test_on_intent_verdict_auto_reason_survives_resolve_cycle() -> None:
"""Mixed-batch case: one tool was auto-approved (policy), another
needs manual approval. The LLM judge fires for the auto-approved
sibling DURING the manual-approval wait. The verdict must land
with ``user_decision="policy"`` and stay that way even after
``resolve_approval`` fires for the pending sibling the prior
bug was that the auto-stamped row got overwritten with
``"approved"``/``"denied"`` by the resolve path."""
storage = MagicMock()
ui = _make_ui()
ui._auto_approve_reasons["c-auto"] = ("policy", 0.0)
with _patch_get_storage(storage):
# LLM verdict fires for the auto-approved sibling.
ui.on_intent_verdict({"verdict_id": "v-auto", "call_id": "c-auto"})
# Now the pending sibling gets a verdict + manual resolve.
ui.on_intent_verdict({"verdict_id": "v-pending", "call_id": "c-pending"})
ui.resolve_approval(True, "looks good")
# Only the pending verdict should be UPDATEd to "approved" — the
# auto-stamped one stays "policy" via its INSERT.
update_calls = {
c.args[0]: c.kwargs.get("user_decision")
for c in storage.update_intent_verdict.call_args_list
}
assert update_calls == {"v-pending": "approved"}
# The auto verdict's INSERT carried the policy reason.
insert_calls = {
c.kwargs["verdict_id"]: c.kwargs["user_decision"]
for c in storage.upsert_intent_verdict.call_args_list
}
assert insert_calls["v-auto"] == "policy"
assert insert_calls["v-pending"] == "pending"
def test_persist_auto_approved_heuristic_verdicts_stamps_reason() -> None:
"""The auto-approve early-return branches in ``approve_tools`` used
to drop heuristic verdicts on the floor auditors couldn't tell
whether the judge ran or the call was simply silently auto-approved.
``_persist_auto_approved_heuristic_verdicts`` closes that gap and
stamps each verdict with the item's reason."""
storage = MagicMock()
ui = _make_ui()
items = [
{
"call_id": "c-1",
"auto_approved": True,
"auto_approve_reason": "blanket",
"_heuristic_verdict": {
"verdict_id": "v-1",
"call_id": "c-1",
"risk_level": "low",
"recommendation": "review",
},
},
# No _heuristic_verdict — skipped (judge didn't run for this item).
{"call_id": "c-2", "auto_approved": True, "auto_approve_reason": "blanket"},
# Not auto_approved — skipped (this helper only handles auto-approved).
{
"call_id": "c-3",
"_heuristic_verdict": {"verdict_id": "v-3", "call_id": "c-3"},
},
]
with _patch_get_storage(storage):
ui._persist_auto_approved_heuristic_verdicts(items)
storage.create_intent_verdicts_bulk.assert_called_once()
rows = storage.create_intent_verdicts_bulk.call_args.args[0]
assert len(rows) == 1
assert rows[0]["verdict_id"] == "v-1"
assert rows[0]["user_decision"] == "blanket"
def test_auto_approve_reasons_ttl_prune_drops_stale_entries() -> None:
"""Lazy TTL eviction at write time: entries older than
``_AUTO_APPROVE_REASON_TTL`` are pruned on the next
``_record_auto_approves`` call. Without this, a session with the
LLM judge disabled would accumulate entries that never get
consumed."""
import time as time_module
storage = MagicMock()
ui = _make_ui()
# Seed two stale entries (well past the TTL).
stale_ts = time_module.time() - ui._AUTO_APPROVE_REASON_TTL - 30.0
ui._auto_approve_reasons["c-stale-1"] = ("policy", stale_ts)
ui._auto_approve_reasons["c-stale-2"] = ("blanket", stale_ts)
items = [
{
"call_id": "c-fresh",
"auto_approved": True,
"auto_approve_reason": "skill",
"func_name": "bash",
}
]
with _patch_get_storage(storage):
ui._record_auto_approves(items)
# Stale entries pruned; only the fresh one remains.
assert "c-stale-1" not in ui._auto_approve_reasons
assert "c-stale-2" not in ui._auto_approve_reasons
assert "c-fresh" in ui._auto_approve_reasons
# ---------------------------------------------------------------------------
# Output guard persistence
# ---------------------------------------------------------------------------
+222
View File
@@ -0,0 +1,222 @@
"""Tests for the storage layer's cross-process ``notify`` / ``listen`` API.
Covers SQLite (synthetic-sweep + in-process fan-out) and PostgreSQL
(real ``LISTEN``/``NOTIFY``). The PG-only cases are gated on the
``--storage-backend=postgresql`` flag so they no-op on default CI runs.
"""
from __future__ import annotations
import threading
import time
import pytest
def _drain_until(stream, predicate, deadline_sec: float = 5.0):
"""Poll ``stream`` until ``predicate`` matches one of the drained notifies.
Returns the matching notify or raises ``TimeoutError``. Tests use
this so timing flakes against the bounded-blocking ``poll`` shape
don't masquerade as logic bugs.
"""
deadline = time.monotonic() + deadline_sec
while time.monotonic() < deadline:
remaining = max(0.05, deadline - time.monotonic())
for n in stream.poll(min(0.5, remaining)):
if predicate(n):
return n
msg = "no matching notify drained before deadline"
raise TimeoutError(msg)
class TestSqliteNotify:
"""SQLite path: in-process fan-out + synthetic sweep."""
def test_notify_no_listeners_is_noop(self, storage):
# No exception, no side effect — safe to always call from dispatch.
storage.notify("services", '{"op": "INSERT"}')
def test_notify_delivers_to_in_process_listener(self, storage):
with storage.listen(["services"]) as stream:
storage.notify("services", '{"op": "INSERT"}')
got = _drain_until(stream, lambda n: n.payload == '{"op": "INSERT"}')
assert got.channel == "services"
# ``pid`` is 0 on the SQLite synthetic path and the sending
# backend's PID on Postgres — both are valid notify shapes,
# so don't assert on the value here.
def test_notify_filters_by_channel(self, storage):
with storage.listen(["services"]) as stream:
storage.notify("other_channel", "ignored")
storage.notify("services", "wanted")
got = _drain_until(stream, lambda n: True)
assert got.payload == "wanted"
def test_multiple_listeners_each_get_event(self, storage):
# Two streams open on the same channel; each gets its own copy.
with storage.listen(["services"]) as s1, storage.listen(["services"]) as s2:
storage.notify("services", "broadcast")
got1 = _drain_until(s1, lambda n: True)
got2 = _drain_until(s2, lambda n: True)
assert got1.payload == "broadcast"
assert got2.payload == "broadcast"
def test_close_stops_stream(self, storage):
with storage.listen(["services"]) as stream:
pass
# After context exit, the stream is closed; poll returns [] without
# blocking. A second close() is idempotent.
assert stream.poll(0.05) == []
stream.close()
def test_synthetic_sweep_emits_after_interval(self, storage, _is_sqlite):
# Synthetic sweep is fundamentally SQLite-specific — the PG path
# uses real ``LISTEN``/``NOTIFY`` and has no sweep tick. Gate
# so the test doesn't false-fail by waiting for a "sweep" notify
# that the PG stream will never produce.
with storage.listen(["services"], sweep_interval=0.1) as stream:
# First poll: not yet at the interval, so likely empty.
stream.poll(0.05)
# Wait past the interval, then poll again — should emit a
# synthetic-sweep notify per declared channel.
time.sleep(0.15)
got = _drain_until(stream, lambda n: n.payload == "sweep")
assert got.channel == "services"
assert got.payload == "sweep"
def test_empty_channel_list_yields_empty_stream(self, storage):
with storage.listen([]) as stream:
# No channels — poll returns [] regardless of how long we wait.
assert stream.poll(0.05) == []
# ---------------------------------------------------------------------------
# PostgreSQL path — gated on --storage-backend=postgresql.
# ---------------------------------------------------------------------------
@pytest.fixture
def _is_postgres(storage):
"""Skip the wrapped test when the active backend isn't Postgres."""
if storage.__class__.__name__ != "PostgreSQLBackend":
pytest.skip("PostgreSQL-specific test")
return True
@pytest.fixture
def _is_sqlite(storage):
"""Skip the wrapped test when the active backend isn't SQLite."""
if storage.__class__.__name__ != "SQLiteBackend":
pytest.skip("SQLite-specific test")
return True
class TestPostgresNotify:
def test_round_trip(self, storage, _is_postgres):
# Open a listener, fire a notify on a regular pooled connection,
# drain the listener within a reasonable bound (PG NOTIFY is
# typically sub-100ms on a local socket).
with storage.listen(["pytest_round_trip"]) as stream:
# Tiny sleep so the LISTEN settles before the NOTIFY fires —
# otherwise the notify can arrive on the connection before
# the LISTEN is registered (race only visible in tests).
time.sleep(0.05)
storage.notify("pytest_round_trip", '{"hello": "world"}')
got = _drain_until(stream, lambda n: True, deadline_sec=3.0)
assert got.channel == "pytest_round_trip"
assert got.payload == '{"hello": "world"}'
assert got.pid > 0
def test_concurrent_notifies_all_arrive(self, storage, _is_postgres):
with storage.listen(["pytest_concurrent"]) as stream:
time.sleep(0.05)
for i in range(5):
storage.notify("pytest_concurrent", str(i))
seen: set[str] = set()
deadline = time.monotonic() + 3.0
while len(seen) < 5 and time.monotonic() < deadline:
for n in stream.poll(0.2):
seen.add(n.payload)
assert seen == {"0", "1", "2", "3", "4"}
def test_close_aborts_blocked_poll(self, storage, _is_postgres):
# poll() should return promptly once close() runs on another thread.
with storage.listen(["pytest_close"]) as stream:
done = threading.Event()
result: list[list] = []
def _poll_long():
result.append(stream.poll(5.0))
done.set()
t = threading.Thread(target=_poll_long, daemon=True)
t.start()
time.sleep(0.1)
stream.close()
assert done.wait(2.0), "close() did not unblock poll()"
# No notify arrived, so the polled batch is empty — but the
# poll loop must have exited well under the 5 s timeout.
assert result == [[]]
class TestServicesTriggerFilter:
"""Migration 053's trigger: fires on real changes, quiet on heartbeats.
PG-only the SQLite path has no trigger and is covered by
:class:`TestSqliteNotify`. Verifies the in-trigger ``IS NOT DISTINCT
FROM`` filter a heartbeat-only UPDATE (same url + same metadata,
only ``last_heartbeat`` changed) must NOT emit a NOTIFY, since
``register_service`` runs the same UPSERT on every 30 s tick × N
nodes and the channel would otherwise flood.
"""
def test_insert_fires_notify(self, storage, _is_postgres):
with storage.listen(["services"]) as stream:
time.sleep(0.05)
storage.register_service("server", "pytest-trigger-node", "http://127.0.0.1:1")
got = _drain_until(stream, lambda n: True, deadline_sec=3.0)
assert got.channel == "services"
assert '"op": "INSERT"' in got.payload or "INSERT" in got.payload
# Cleanup so concurrent suites don't pick up the row.
storage.deregister_service("server", "pytest-trigger-node")
def test_delete_fires_notify(self, storage, _is_postgres):
storage.register_service("server", "pytest-trigger-node-del", "http://127.0.0.1:2")
with storage.listen(["services"]) as stream:
time.sleep(0.05)
storage.deregister_service("server", "pytest-trigger-node-del")
got = _drain_until(stream, lambda n: True, deadline_sec=3.0)
assert "DELETE" in got.payload
def test_url_change_update_fires_notify(self, storage, _is_postgres):
storage.register_service("server", "pytest-trigger-node-url", "http://127.0.0.1:3")
with storage.listen(["services"]) as stream:
time.sleep(0.05)
# UPSERT with different url — UPDATE path with url diff,
# trigger must fire.
storage.register_service("server", "pytest-trigger-node-url", "http://127.0.0.1:9")
got = _drain_until(stream, lambda n: True, deadline_sec=3.0)
assert "UPDATE" in got.payload
storage.deregister_service("server", "pytest-trigger-node-url")
def test_heartbeat_only_update_is_quiet(self, storage, _is_postgres):
# Open the LISTEN session FIRST so PG delivers the INSERT NOTIFY
# to this connection — pg_notify routes only to sessions that
# have LISTENed at COMMIT time, so an INSERT committed before the
# listen opens would be lost and the drain would time out instead
# of exercising the heartbeat-quiet check below.
with storage.listen(["services"]) as stream:
time.sleep(0.05)
storage.register_service("server", "pytest-trigger-node-hb", "http://127.0.0.1:4")
# Drain the INSERT notify so subsequent polls see only what
# heartbeats emit (if anything).
_drain_until(stream, lambda n: True, deadline_sec=2.0)
# Now fire a heartbeat tick — same url + same metadata,
# only last_heartbeat updates. Trigger must NOT emit.
storage.heartbeat_service("server", "pytest-trigger-node-hb")
# Poll long enough that any spurious notify would have
# arrived; the channel must stay silent.
spurious = stream.poll(0.5)
assert spurious == [], f"heartbeat-only update emitted unexpected notify: {spurious}"
storage.deregister_service("server", "pytest-trigger-node-hb")
+32 -39
View File
@@ -232,59 +232,52 @@ class TestSoftCap:
# ---------------------------------------------------------------------------
# valid_until predicate
# Predicate independence
# ---------------------------------------------------------------------------
class TestValidUntil:
"""The ``valid_until`` predicate captured at dispatch time re-checks
the watch's ``active`` flag at drain time, so a cancelled watch's
last splat doesn't ride out a future wake.
class TestPredicateIndependence:
"""The watch closure does NOT wire a ``valid_until`` predicate.
Earlier the closure wired ``_still_active`` (re-reading
``is_watch_active`` at drain time). That predicate raced
``WatchRunner._poll_watch``'s commit of ``active=False`` and silently
dropped every terminal fire. The closure now enqueues without a
predicate; entries survive drain regardless of the row's ``active``
column state.
"""
def test_valid_until_drops_when_watch_inactive(self, tmp_db, monkeypatch):
def test_drain_delivers_even_when_storage_reports_inactive(self, tmp_db, monkeypatch):
session = _make_session_for_dispatch()
_runner, dispatch = _register_runner(session)
# Storage stub returns False at drain time.
is_active_calls = patch_session_storage(monkeypatch, active=False)
dispatch(_reminder("body"), "watch-1")
# Drain fires the predicate; entry should NOT be delivered.
out = session._nudge_queue.drain({"any"})
assert out == []
# Predicate ran once with the dispatched watch_id.
assert is_active_calls == ["watch-1"]
def test_valid_until_drops_when_storage_raises(self, tmp_db, monkeypatch):
"""The closure's broad-except in the predicate translates a
storage-layer exception to ``False`` so the drain doesn't
propagate; the predicate captured ``watch_id`` correctly
(otherwise storage wouldn't even be touched).
"""
session = _make_session_for_dispatch()
_runner, dispatch = _register_runner(session)
patch_session_storage(monkeypatch, raise_on_is_active=True)
dispatch(_reminder("body"), "watch-bound-id")
out = session._nudge_queue.drain({"any"})
assert out == []
def test_valid_until_delivers_when_watch_active(self, tmp_db, monkeypatch):
"""Happy-path counter-test for the predicate above: the entry
DOES drain when the watch is still active.
"""
session = _make_session_for_dispatch()
_runner, dispatch = _register_runner(session)
patch_session_storage(monkeypatch, active=True)
# Even if storage reports active=False, the entry should still
# drain — no predicate to drop it.
patch_session_storage(monkeypatch, active=False)
dispatch(_reminder("body"), "watch-1")
out = session._nudge_queue.drain({"any"})
assert len(out) == 1
assert out[0][0] == "watch_triggered"
def test_dispatch_never_calls_is_watch_active(self, tmp_db, monkeypatch):
"""Pin the invariant directly: the closure must NOT consult
``storage.is_watch_active`` anywhere along the enqueue + drain
path. Without this assertion, a future change that re-wires
an ``is_watch_active`` predicate would silently bring back the
bug that motivates this whole module.
"""
session = _make_session_for_dispatch()
_runner, dispatch = _register_runner(session)
is_active_calls = patch_session_storage(monkeypatch, active=True)
dispatch(_reminder("body"), "watch-bound-id")
session._nudge_queue.drain({"any"})
assert is_active_calls == [], (
f"watch closure must not call is_watch_active; got {is_active_calls!r}"
)
# ---------------------------------------------------------------------------
# Concurrency
+284
View File
@@ -24,11 +24,15 @@ the structural integration gate for the watch switchover.
from __future__ import annotations
import contextlib
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from tests._helpers import patch_session_storage
from turnstone.core.session import ChatSession
from turnstone.core.storage import get_storage
from turnstone.core.watch import WatchRunner
@@ -272,3 +276,283 @@ def test_watch_dispatch_through_restore_fn_lands_on_rehydrated_session(tmp_db, m
# Original session's queue stays empty — the dispatch did NOT
# accidentally route back to it.
assert len(original._nudge_queue) == 0
@pytest.mark.parametrize(
("stop_on", "max_polls", "label"),
[
('"HIT" in output', 100, "stop_on_fired"),
(None, 1, "max_polls_reached"),
],
)
def test_poll_watch_terminal_fire_survives_drain(
tmp_db: str,
monkeypatch: pytest.MonkeyPatch,
stop_on: str | None,
max_polls: int,
label: str,
) -> None:
"""Regression for the dispatch-ordering bug.
With the broken ordering (``update_watch(active=False)`` before
``_dispatch_result``) plus the ``_still_active`` ``valid_until``
predicate that re-reads ``is_watch_active`` at drain time, every
terminal watch fire was silently dropped the closure enqueued
the entry but the predicate immediately invalidated it because
the row's ``active`` flag had already been flipped to ``0`` in
the same poll. The model never saw the fire.
This test drives a REAL ``WatchRunner._poll_watch`` against a real
``tmp_db`` watch row (no ``patch_session_storage(active=True)``
stub that stub is exactly what masked the bug in earlier tests).
Covers both terminal paths: ``stop_on`` condition matched and
``poll_count >= max_polls`` reached.
"""
session = _make_session()
storage = get_storage()
runner = WatchRunner(storage=storage, node_id="test-node")
session.set_watch_runner(runner)
storage.create_watch(
watch_id=f"w-regression-{label}",
ws_id=session._ws_id,
node_id="test-node",
name=f"regression-{label}",
command="echo HIT",
interval_secs=10.0,
stop_on=stop_on,
max_polls=max_polls,
created_by="model",
next_poll="1970-01-01T00:00:00",
)
# Spy ``enqueue`` so the assertion can distinguish "dispatch never
# called" (a different bug class) from "dispatch enqueued but the
# predicate dropped it at drain" (this bug).
enqueue_calls: list[tuple[str, str, str]] = []
real_enqueue = session._nudge_queue.enqueue
def _spy_enqueue(*args: Any, **kwargs: Any) -> None:
enqueue_calls.append((args[0], args[1][:40], args[2]))
return real_enqueue(*args, **kwargs)
monkeypatch.setattr(session._nudge_queue, "enqueue", _spy_enqueue)
# For the max_polls=1 case the first poll has prev_output=None and
# would not normally fire on output change; the max_polls branch
# at watch.py:412-414 still marks is_final=True so dispatch runs.
due = storage.list_due_watches("2099-01-01T00:00:00")
matching = [r for r in due if r["watch_id"] == f"w-regression-{label}"]
assert len(matching) == 1, f"watch row not picked up by list_due_watches: {due!r}"
runner._poll_watch(matching[0])
assert len(enqueue_calls) == 1, (
f"_poll_watch did not enqueue exactly one fire (got {enqueue_calls!r}); "
"this is a different bug from the predicate-drop regression"
)
assert enqueue_calls[0][0] == "watch_triggered"
assert storage.is_watch_active(f"w-regression-{label}") is False, (
"terminal fire should have committed active=False on the row"
)
# The key assertion: drain delivers the entry. Pre-fix this
# returned ``[]`` because the ``_still_active`` predicate re-read
# ``active=0``. Post-fix the watch closure no longer wires a
# predicate and the entry survives.
out = session._nudge_queue.drain({"any"})
assert len(out) == 1, (
"Watch fire was enqueued but never reached drain — dispatch-ordering "
"regression. Check that WatchRunner._poll_watch dispatches BEFORE "
"committing active=False, and that the watch closure in "
"ChatSession.set_watch_runner does not wire an is_watch_active "
"predicate."
)
nt, text, _meta = out[0]
assert nt == "watch_triggered"
assert "HIT" in text
def test_cancel_reports_already_completed_for_auto_cancelled_watch(tmp_db: str) -> None:
"""After a watch fires and auto-cancels, the cancel-by-name path
should report 'already completed' rather than 'not found'.
Pre-fix, ``_exec_watch`` cancel looked the watch up via
``list_watches_for_ws`` which filters ``active==1``, so a recently-
auto-cancelled row was invisible and the model got the same
'not found' message it would for a typo'd name. Post-fix the
cancel path uses ``find_watch_by_name`` (no active filter) and
branches on ``row["active"]``.
"""
session = _make_session()
storage = get_storage()
storage.create_watch(
watch_id="w-completed-1",
ws_id=session._ws_id,
node_id="test-node",
name="completed-watch",
command="echo x",
interval_secs=10.0,
stop_on=None,
max_polls=100,
created_by="model",
next_poll="",
)
# Simulate the post-fire state.
storage.update_watch("w-completed-1", active=False, next_poll="")
_call_id, msg = session._exec_watch(
{"call_id": "c1", "action": "cancel", "watch_name": "completed-watch"}
)
assert "not found" not in msg.lower()
assert "completed" in msg.lower()
def test_cancel_reports_not_found_for_unknown_watch(tmp_db: str) -> None:
"""The 'not found' message still applies when the watch genuinely
does not exist make sure the new ``find_watch_by_name`` path
didn't accidentally turn every cancel into 'already completed'.
"""
session = _make_session()
_call_id, msg = session._exec_watch(
{"call_id": "c1", "action": "cancel", "watch_name": "ghost-watch"}
)
assert "not found" in msg.lower()
def test_poll_watch_retry_deactivate_after_update_watch_failure(
tmp_db: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``_terminal_dispatched`` lifecycle: if ``update_watch`` raises
AFTER ``_dispatch_result`` shipped the reminder for a terminal
fire, the next ``_poll_watch`` tick MUST retry the row write
(so the row stops appearing in ``list_due_watches``) and MUST NOT
re-dispatch the reminder the model already saw.
This is the keystone path that prevents duplicate-fire under
transient storage failure. Pre-this-test, the entire branch was
unexercised.
"""
session = _make_session()
storage = get_storage()
runner = WatchRunner(storage=storage, node_id="test-node")
session.set_watch_runner(runner)
watch_id = "w-retry-1"
storage.create_watch(
watch_id=watch_id,
ws_id=session._ws_id,
node_id="test-node",
name="retry-watch",
command="echo HIT",
interval_secs=10.0,
stop_on='"HIT" in output',
max_polls=100,
created_by="model",
next_poll="1970-01-01T00:00:00",
)
enqueue_calls: list[tuple[str, str]] = []
real_enqueue = session._nudge_queue.enqueue
def _spy_enqueue(*args: Any, **kwargs: Any) -> None:
enqueue_calls.append((args[0], args[1][:32]))
return real_enqueue(*args, **kwargs)
monkeypatch.setattr(session._nudge_queue, "enqueue", _spy_enqueue)
# Stage 1 — first poll. ``update_watch`` raises AFTER dispatch.
real_update = storage.update_watch
update_raise = {"armed": True}
def _failing_update(wid: str, **fields: Any) -> bool:
if update_raise["armed"]:
raise RuntimeError("simulated transient storage failure")
return real_update(wid, **fields)
monkeypatch.setattr(storage, "update_watch", _failing_update)
due = storage.list_due_watches("2099-01-01T00:00:00")
matching = [r for r in due if r["watch_id"] == watch_id]
assert len(matching) == 1
# ``_poll_watch`` doesn't catch the storage error; the outer
# ``_tick`` would log it. Suppress here so the test owns the
# boundary and continues to its assertions.
with contextlib.suppress(RuntimeError):
runner._poll_watch(matching[0])
# Dispatch ran exactly once and the watch_id sits in the
# terminal-dispatched set awaiting retry.
assert len(enqueue_calls) == 1
assert enqueue_calls[0][0] == "watch_triggered"
assert watch_id in runner._terminal_dispatched
# The row is still active=1 because update_watch raised. It
# would re-appear in list_due_watches on the next tick.
assert storage.is_watch_active(watch_id) is True
# Stage 2 — second poll. Storage now succeeds; retry-deactivate
# branch must commit active=False WITHOUT re-dispatching.
update_raise["armed"] = False
due = storage.list_due_watches("2099-01-01T00:00:00")
matching = [r for r in due if r["watch_id"] == watch_id]
assert len(matching) == 1
runner._poll_watch(matching[0])
# Exactly one dispatch in total — the retry path took the
# short-circuit return at the top of _poll_watch.
assert len(enqueue_calls) == 1, f"retry-deactivate must not re-dispatch; got {enqueue_calls!r}"
# Row is now inactive (the retry path's update_watch landed).
assert storage.is_watch_active(watch_id) is False
# Set is cleared so future watches with the same id (unlikely) /
# process memory doesn't accumulate.
assert watch_id not in runner._terminal_dispatched
def test_cancel_clears_pending_terminal_dispatched_entry(
tmp_db: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If ``update_watch`` raised after dispatch, leaving a pending
entry in ``_terminal_dispatched``, and the user then cancels the
watch out-of-band, the retry-deactivate branch never gets to run
(the cancel sets ``next_poll=""`` which removes the row from
``list_due_watches``). The cancel path itself must discard the
pending entry; otherwise the runner leaks ``watch_id``s for the
process lifetime.
"""
session = _make_session()
storage = get_storage()
runner = WatchRunner(storage=storage, node_id="test-node")
session.set_watch_runner(runner)
watch_id = "w-leak-1"
storage.create_watch(
watch_id=watch_id,
ws_id=session._ws_id,
node_id="test-node",
name="leak-watch",
command="echo x",
interval_secs=10.0,
stop_on=None,
max_polls=100,
created_by="model",
next_poll="",
)
# Simulate: dispatch shipped, update_watch raised, watch_id sits
# in the runner's pending set.
with runner._terminal_dispatched_lock:
runner._terminal_dispatched.add(watch_id)
# User cancels. Because the cancel writes active=False, next_poll="",
# the row leaves list_due_watches and the runner's retry-deactivate
# branch never executes for it. The cancel must discard the entry.
storage.update_watch(watch_id, active=False, next_poll="")
session._exec_watch({"call_id": "c1", "action": "cancel", "watch_name": "leak-watch"})
assert watch_id not in runner._terminal_dispatched
+89
View File
@@ -2,6 +2,10 @@
from __future__ import annotations
import sqlalchemy as sa
from turnstone.core.storage._schema import watches as watches_table
def _make_watch_kwargs(**overrides):
"""Build default kwargs for create_watch."""
@@ -101,6 +105,91 @@ class TestWatchListQueries:
db.update_watch("w1", active=False)
assert db.list_watches_for_ws("ws-1") == []
def test_find_by_name_returns_inactive(self, db):
"""``find_watch_by_name`` ignores the active filter — that is
what lets the cancel-by-name UX distinguish 'already completed'
from 'no such watch.'
"""
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1", name="completed"))
db.update_watch("w1", active=False)
row = db.find_watch_by_name("ws-1", "completed")
assert row is not None
assert row["watch_id"] == "w1"
assert not row["active"]
def test_find_by_name_matches_watch_id_prefix(self, db):
db.create_watch(**_make_watch_kwargs(watch_id="abcdef123", ws_id="ws-1", name="x"))
row = db.find_watch_by_name("ws-1", "abc")
assert row is not None
assert row["watch_id"] == "abcdef123"
def test_find_by_name_scoped_to_ws(self, db):
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1", name="shared"))
db.create_watch(**_make_watch_kwargs(watch_id="w2", ws_id="ws-2", name="shared"))
row = db.find_watch_by_name("ws-1", "shared")
assert row is not None
assert row["watch_id"] == "w1"
def test_find_by_name_returns_none_when_missing(self, db):
assert db.find_watch_by_name("ws-1", "ghost") is None
def test_find_by_name_empty_input_returns_none(self, db):
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1", name="x"))
assert db.find_watch_by_name("ws-1", "") is None
def test_find_by_name_treats_percent_as_literal(self, db):
"""A model-supplied '%' must NOT match arbitrary watch_ids.
Pre-escape, ``watch_id.like(f"{name_or_prefix}%")`` would
interpret '%' as 'match anything' and pick up the first row in
the workstream regardless of name.
"""
db.create_watch(**_make_watch_kwargs(watch_id="w1", ws_id="ws-1", name="real-watch"))
assert db.find_watch_by_name("ws-1", "%") is None
def test_find_by_name_treats_underscore_as_literal(self, db):
"""Same as the '%' case for the single-char LIKE wildcard."""
db.create_watch(**_make_watch_kwargs(watch_id="abcd", ws_id="ws-1", name="real-watch"))
# '_' would otherwise match any single char, picking up
# watch_ids beginning with 'a', 'b', etc.
assert db.find_watch_by_name("ws-1", "_") is None
def test_find_by_name_prefers_active_over_newer_inactive(self, db):
"""If a same-name pair exists where the inactive row is NEWER
than the active row, find_watch_by_name must still return the
active row. Pre-fix the query was ``ORDER BY created DESC
LIMIT 1`` which would return the newer inactive row and
cause the cancel UX to report 'already completed' for a name
whose live row is still polling.
Reachable in practice because storage allows out-of-band
writes (e.g. ``delete_watches_for_ws`` cleanup followed by
re-create, an admin manually flipping ``active``, or test
scaffolding) that bypass the create-time duplicate-name
guard.
"""
# Older active watch.
db.create_watch(**_make_watch_kwargs(watch_id="w-active", ws_id="ws-1", name="recurring"))
# Newer inactive watch with the same name. ``create_watch``
# stamps ``created`` to ``now`` at second resolution, so we
# bypass the API to give the inactive row a deterministically
# later timestamp.
db.create_watch(**_make_watch_kwargs(watch_id="w-inactive", ws_id="ws-1", name="recurring"))
with db._conn() as conn:
conn.execute(
sa.update(watches_table)
.where(watches_table.c.watch_id == "w-inactive")
.values(active=0, next_poll="", created="2099-01-01T00:00:00")
)
conn.commit()
row = db.find_watch_by_name("ws-1", "recurring")
assert row is not None
assert row["watch_id"] == "w-active"
assert row["active"]
def test_list_for_node(self, db):
db.create_watch(**_make_watch_kwargs(watch_id="w1", node_id="n1"))
db.create_watch(**_make_watch_kwargs(watch_id="w2", node_id="n1"))
+5 -5
View File
@@ -29,7 +29,7 @@ class TestVersionHtml:
def test_vendored_katex_skipped(self):
from turnstone.core.web_helpers import version_html
html = '<link rel="stylesheet" href="/shared/katex-0.16.44/katex.min.css">'
html = '<link rel="stylesheet" href="/shared/katex-0.16.47/katex.min.css">'
result = version_html(html)
assert result == html # unchanged
@@ -43,14 +43,14 @@ class TestVersionHtml:
def test_vendored_mermaid_skipped(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/shared/mermaid-11.14.0/mermaid.min.js"></script>'
html = '<script src="/shared/mermaid-11.15.0/mermaid.min.js"></script>'
result = version_html(html)
assert result == html # unchanged
def test_vendored_hls_skipped(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/shared/hls-1.6.15/hls.min.js"></script>'
html = '<script src="/shared/hls-1.6.16/hls.min.js"></script>'
result = version_html(html)
assert result == html # unchanged
@@ -76,7 +76,7 @@ class TestVersionHtml:
html = (
'<link rel="stylesheet" href="/shared/base.css">\n'
'<link rel="stylesheet" href="/shared/katex-0.16.44/katex.min.css">\n'
'<link rel="stylesheet" href="/shared/katex-0.16.47/katex.min.css">\n'
'<link rel="stylesheet" href="/static/style.css">\n'
'<script src="/shared/utils.js"></script>\n'
'<script src="/shared/hljs-11.11.1/highlight.min.js"></script>\n'
@@ -88,7 +88,7 @@ class TestVersionHtml:
assert f'/shared/utils.js?v={__version__}"' in result
assert f'/static/app.js?v={__version__}"' in result
# Vendored libs unchanged
assert '/shared/katex-0.16.44/katex.min.css"' in result
assert '/shared/katex-0.16.47/katex.min.css"' in result
assert '/shared/hljs-11.11.1/highlight.min.js"' in result
def test_version_matches_package(self):
+7
View File
@@ -62,6 +62,13 @@
[database]
# url = "" # postgres://user:pass@host/db or /path/to.db
# env: TURNSTONE_DB_URL
# listen_url = "" # direct-to-postgres URL for the console's
# dedicated LISTEN connection. Set this when
# `url` points at pgbouncer in transaction
# pooling mode (LISTEN holds session state and
# is incompatible with transaction pooling —
# see docs/pgbouncer.md). Defaults to `url`
# when unset. env: TURNSTONE_DB_LISTEN_URL
# SSL params (passed through to SQLAlchemy connection):
# sslmode = "prefer" # disable, allow, prefer, require, verify-ca, verify-full
# sslrootcert = "" # path to CA cert for verify-ca/verify-full
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.5.12"
__version__ = "1.6.0a2"
+40 -14
View File
@@ -12,14 +12,36 @@ import uuid
from typing import Any
def _get_storage() -> Any:
"""Initialize and return the storage backend."""
def _get_storage(args: argparse.Namespace) -> Any:
"""Initialize and return the storage backend.
Precedence (matches turnstone-server): CLI / config.toml ``[database]``
> ``TURNSTONE_DB_*`` env vars > hardcoded defaults.
"""
from turnstone.core.storage import init_storage
db_backend = os.environ.get("TURNSTONE_DB_BACKEND", "sqlite")
db_url = os.environ.get("TURNSTONE_DB_URL", "")
db_path = os.environ.get("TURNSTONE_DB_PATH", "")
return init_storage(db_backend, path=db_path, url=db_url)
def _pick(arg_name: str, env_name: str, default: str = "") -> Any:
# `is not None` (not truthy) so a legitimate falsy TOML value
# like `pool_size = 0` or `url = ""` still beats the env fallback.
val = getattr(args, arg_name, None)
if val is not None:
return val
return os.environ.get(env_name, default)
db_backend = str(_pick("db_backend", "TURNSTONE_DB_BACKEND", "sqlite"))
db_url = str(_pick("db_url", "TURNSTONE_DB_URL"))
db_path = str(_pick("db_path", "TURNSTONE_DB_PATH"))
db_pool_size = int(_pick("db_pool_size", "TURNSTONE_DB_POOL_SIZE", "2"))
return init_storage(
db_backend,
path=db_path,
url=db_url,
pool_size=db_pool_size,
sslmode=str(_pick("db_sslmode", "TURNSTONE_DB_SSLMODE")),
sslrootcert=str(_pick("db_sslrootcert", "TURNSTONE_DB_SSLROOTCERT")),
sslcert=str(_pick("db_sslcert", "TURNSTONE_DB_SSLCERT")),
sslkey=str(_pick("db_sslkey", "TURNSTONE_DB_SSLKEY")),
)
def _cmd_create_user(args: argparse.Namespace) -> None:
@@ -37,7 +59,7 @@ def _cmd_create_user(args: argparse.Namespace) -> None:
print("Error: invalid username (1-64 chars: letters, digits, . _ -)", file=sys.stderr)
sys.exit(1)
storage = _get_storage()
storage = _get_storage(args)
user_id = uuid.uuid4().hex
# Prompt for password
@@ -76,7 +98,7 @@ def _cmd_create_user(args: argparse.Namespace) -> None:
def _cmd_create_token(args: argparse.Namespace) -> None:
from turnstone.core.auth import generate_token, hash_token, token_prefix
storage = _get_storage()
storage = _get_storage(args)
if storage.get_user(args.user) is None:
print(f"Error: user {args.user} not found", file=sys.stderr)
@@ -110,7 +132,7 @@ def _cmd_create_token(args: argparse.Namespace) -> None:
def _cmd_list_users(args: argparse.Namespace) -> None:
storage = _get_storage()
storage = _get_storage(args)
users = storage.list_users()
if not users:
print("No users found.")
@@ -120,7 +142,7 @@ def _cmd_list_users(args: argparse.Namespace) -> None:
def _cmd_list_tokens(args: argparse.Namespace) -> None:
storage = _get_storage()
storage = _get_storage(args)
tokens = storage.list_api_tokens(args.user)
if not tokens:
print(f"No tokens found for user {args.user}.")
@@ -134,7 +156,7 @@ def _cmd_list_tokens(args: argparse.Namespace) -> None:
def _cmd_revoke_token(args: argparse.Namespace) -> None:
storage = _get_storage()
storage = _get_storage(args)
if storage.delete_api_token(args.token_id):
print(f"Revoked token {args.token_id}")
else:
@@ -297,7 +319,7 @@ def _cmd_list_node_metadata(args: argparse.Namespace) -> None:
"""List metadata for a node."""
import json
storage = _get_storage()
storage = _get_storage(args)
rows = storage.get_node_metadata(args.node_id)
if not rows:
print(f"No metadata for node: {args.node_id}")
@@ -324,7 +346,7 @@ def _cmd_set_node_metadata(args: argparse.Namespace) -> None:
"""Set a metadata key on a node."""
import json
storage = _get_storage()
storage = _get_storage(args)
# Check for auto-source conflict
existing = storage.get_node_metadata(args.node_id)
@@ -345,7 +367,7 @@ def _cmd_set_node_metadata(args: argparse.Namespace) -> None:
def _cmd_delete_node_metadata(args: argparse.Namespace) -> None:
"""Delete a metadata key from a node."""
storage = _get_storage()
storage = _get_storage(args)
existing = storage.get_node_metadata(args.node_id)
for r in existing:
@@ -395,6 +417,10 @@ def main() -> None:
prog="turnstone-admin",
description="Turnstone user and token administration",
)
from turnstone.core.config import add_config_arg, apply_config
add_config_arg(parser)
apply_config(parser, ["database"])
sub = parser.add_subparsers(dest="command")
p_cu = sub.add_parser("create-user", help="Create a new user")
+40
View File
@@ -25,9 +25,13 @@ import httpx_sse
from turnstone.core.workstream import WorkstreamKind
if TYPE_CHECKING:
from collections.abc import Callable
from turnstone.console.metrics import ConsoleMetrics
from turnstone.console.notify_dispatcher import NotifyDispatcher
from turnstone.console.router import ConsoleRouter
from turnstone.core.auth import ServiceTokenManager
from turnstone.core.storage._notify import Notify
from turnstone.core.storage._protocol import StorageBackend
log = logging.getLogger("turnstone.console.collector")
@@ -73,6 +77,7 @@ class ClusterCollector:
tls_cert: tuple[str, str] | None = None,
router: ConsoleRouter | None = None,
console_metrics: ConsoleMetrics | None = None,
notify_dispatcher: NotifyDispatcher | None = None,
):
self._storage = storage
self._discovery_interval = discovery_interval
@@ -82,6 +87,8 @@ class ClusterCollector:
self._console_metrics = console_metrics
self._tls_verify = tls_verify
self._tls_cert = tls_cert
self._notify_dispatcher = notify_dispatcher
self._notify_unsubscribe: Callable[[], None] | None = None
self._lock = threading.Lock()
self._nodes: dict[str, NodeSnapshot] = {}
@@ -128,6 +135,15 @@ class ClusterCollector:
def start(self) -> None:
"""Start background threads."""
self._running = True
# Subscribe to the ``services`` channel for reactive node discovery.
# NOTIFY-driven wake-ups bring new-node visibility from up-to-60 s
# (next discovery tick) down to ~500 ms on Postgres; the 60 s
# discovery loop still runs as the backstop for crash-shaped node
# loss (NOTIFY only fires on actual writes, not on crash exits).
if self._notify_dispatcher is not None:
self._notify_unsubscribe = self._notify_dispatcher.subscribe(
"services", self._on_services_notify
)
for target, name in [
(self._discovery_loop, "console-discovery"),
(self._sse_manager_thread, "console-sse"),
@@ -145,6 +161,10 @@ class ClusterCollector:
its ``finally`` cleanup (cancel tasks, close AsyncClient).
"""
self._running = False
if self._notify_unsubscribe is not None:
with contextlib.suppress(Exception):
self._notify_unsubscribe()
self._notify_unsubscribe = None
# Request cancellation of all SSE tasks so they don't block the
# manager's cleanup. The manager coroutine exits when _running is
# False and handles remaining task cancellation in its finally block.
@@ -156,6 +176,26 @@ class ClusterCollector:
t.join(timeout=5)
log.info("ClusterCollector stopped")
def _on_services_notify(self, notify: Notify) -> None:
"""Run a discovery tick when the ``services`` channel fires.
The dispatcher delivers both real Postgres notifications and
synthetic ``reconcile`` wake-ups after a reconnect both shape
the same way: re-read ``services`` and diff against in-memory
state. Re-uses :meth:`_discover_nodes` so the timer-driven
backstop and the NOTIFY-driven fast-path share one code path.
"""
from turnstone.core.storage._registry import StorageUnavailableError
if not self._running:
return
try:
self._discover_nodes()
except StorageUnavailableError:
pass # already logged by storage layer
except Exception:
log.exception("Node discovery error (notify-driven)")
def _fanout(self, event: dict[str, Any]) -> None:
"""Copy an event to all registered SSE listener queues."""
with self._listeners_lock:
+27
View File
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any
from turnstone.core import session_worker
from turnstone.core.adapters._ui_cleanup import cleanup_session_ui
from turnstone.core.child_event_bus import ChildEventBus
from turnstone.core.child_source import ClusterChildSource
from turnstone.core.children_registry import ChildrenRegistry
from turnstone.core.log import get_logger
@@ -67,6 +68,14 @@ class CoordinatorAdapter:
# method names which still exist as thin shims for the
# cluster-routing + cleanup callers.
self._registry = ChildrenRegistry()
# In-process wakeup primitive for ``wait_for_workstream``. The
# dispatch sink (:meth:`_dispatch_child_event`) calls
# ``notify(child_ws_id)`` after each translated child event;
# waiters block on per-call ``threading.Event``s instead of
# polling storage. Owned by the adapter so the manager-level
# exposure can simply delegate; ``CoordinatorClient`` picks it
# up via the coord client factory closure.
self._child_event_bus = ChildEventBus()
# Cross-node child events arrive via ``ClusterChildSource``
# (Stage 3 Step 2): a strategy that subscribes to the
# collector's listener channel and runs a daemon thread that
@@ -77,6 +86,17 @@ class CoordinatorAdapter:
# the collector reference is available.
self._child_source: ClusterChildSource | None = None
@property
def child_event_bus(self) -> ChildEventBus:
"""In-process wakeup bus consumed by ``wait_for_workstream``.
Exposed so the coord client factory in the console bootstrap
can pass it to :class:`CoordinatorClient` without reaching
into a private attr, and so :class:`SessionManager` can
delegate its own ``child_event_bus`` property here.
"""
return self._child_event_bus
def attach(self, manager: SessionManager) -> None:
"""Late-bind the owning :class:`SessionManager`.
@@ -639,6 +659,13 @@ class CoordinatorAdapter:
"detail": event.get("detail") or {},
}
_enqueue_on_ui(owning_ws.ui, coord_id, child_event)
# Wake any in-process ``wait_for_workstream`` subscriber on
# this child. Notify runs AFTER the UI enqueue so the SSE
# fan-out keeps priority (a wait that wakes early sees the
# state already enqueued for its owning dashboard). The bus
# is a no-op when no waiter is registered — the steady
# state for the hot dispatch path.
self._child_event_bus.notify(ws_id)
def _enqueue_on_ui(ui: Any, coord_ws_id: str, payload: dict[str, Any]) -> None:
+473 -74
View File
@@ -73,12 +73,27 @@ WAIT_MAX_WS_IDS: int = 32
# wait_for_workstream again with the same ws_ids — each call re-arms freshly.
WAIT_MAX_TIMEOUT: float = 600.0
# Storage-poll cadence. 500ms is short enough that the wait terminates
# promptly after a child finishes (well under the human-perceptible-latency
# floor), and long enough that a 60s wait incurs at most 120 cheap row
# reads — still cheaper than the 20+ inspect_workstream model turns the
# tool replaces.
WAIT_POLL_INTERVAL: float = 0.5
# Maximum ``event.wait`` interval in the bus-driven wait loop.
# A long-running stuck child would otherwise look dead in the sidebar UI
# because the ``wait_progress`` SSE emission piggybacks on the wait loop
# — capping at 2 s keeps the heartbeat visible without flooding storage.
# Today's polling effectively snapshots every 500 ms; 2 s preserves a
# similar liveness feel while cutting per-listener SSE traffic ~4x in the
# steady-state-quiescent case. Tunable post-merge if profiling shows
# storage-read pressure on state-change wakes.
#
# **Worst-case completion latency**: 2 s. ``SessionManager.set_state``
# buffers non-ERROR storage writes through ``StateWriter`` (async-flushed
# at ~1 s cadence) while ``emit_state`` fans the event out immediately —
# a bus-driven wake can therefore beat the flusher and read pre-transition
# state on a terminal transition, then re-block on ``event.wait`` until
# the heartbeat cap fires. Pre-bus the 0.5 s poll bounded this at 0.5 s.
# Going to 2 s is intentional: the 4x SSE-traffic reduction in the
# steady-state-quiescent case outweighs the worst-case latency
# regression on the most common terminal transition, and a model issuing
# a follow-up ``inspect_workstream`` (the pre-bus pattern this tool
# replaces) was already paying multi-second model-turn latency per probe.
WAIT_HEARTBEAT_INTERVAL: float = 2.0
# Per-ws cap on the inline ``message`` field bundled into wait_for_workstream
# results. Sized so a fan-out of 32 children at the cap is ~320 KiB of
@@ -184,6 +199,7 @@ def load_task_envelope(storage: Any, ws_id: str) -> tuple[dict[str, Any], bool]:
if TYPE_CHECKING:
from collections.abc import Callable
from turnstone.core.child_event_bus import ChildEventBus
from turnstone.core.storage._protocol import StorageBackend
log = get_logger(__name__)
@@ -313,6 +329,7 @@ class CoordinatorClient:
user_id: str,
timeout: float = 30.0,
http_client: httpx.Client | None = None,
child_event_bus: ChildEventBus,
) -> None:
self._base_url = console_base_url.rstrip("/")
self._storage = storage
@@ -325,6 +342,12 @@ class CoordinatorClient:
# with the coordinator session.
self._http = http_client or httpx.Client(timeout=timeout)
self._owns_http = http_client is None
# In-process wakeup bus for ``wait_for_workstream``. The wait
# loop blocks on a ``threading.Event`` keyed by ws_id and only
# re-snapshots storage on state-change wakes or the heartbeat
# cap. Owned by ``CoordinatorAdapter`` in production; tests
# pass their own instance.
self._child_event_bus = child_event_bus
# tasks per-ws lock cache — populated lazily by _task_lock().
# Single-session so a plain dict behind a coarse lock is fine;
# WeakValueDictionary isn't needed (entries live as long as the
@@ -447,6 +470,27 @@ class CoordinatorClient:
return False
return bool(row.get("user_id")) and row.get("user_id") == self._user_id
def _row_in_own_subtree(self, ws_id: str, row: dict[str, Any] | None) -> bool:
"""Row-level subtree predicate sharing one home for read paths.
Both :meth:`wait_for_workstream`'s pre-loop ownership filter and
its inner ``_snapshot_all`` already have the workstream row in
hand (from ``get_workstreams_batch``). Funneling them through
the same 4-line check keeps the predicate in lockstep with
:meth:`_is_own_subtree` (used by mutating ops) both require
``parent_ws_id`` AND ``user_id`` parity so a corrupted or
forged ``parent_ws_id`` alone can't satisfy the gate on either
path. Returns False on a missing / None row so callers can
safely pass ``rows.get(wid)``.
"""
if ws_id == self._coord_ws_id:
return True
if row is None:
return False
if row.get("parent_ws_id") != self._coord_ws_id:
return False
return bool(row.get("user_id")) and row.get("user_id") == self._user_id
# -- model-invoked mutating ops (HTTP) ---------------------------------
def spawn(
@@ -562,7 +606,7 @@ class CoordinatorClient:
_WAIT_TERMINAL_STATES: ClassVar[frozenset[str]] = WAIT_TERMINAL_STATES
_WAIT_MAX_WS_IDS: ClassVar[int] = WAIT_MAX_WS_IDS
_WAIT_MAX_TIMEOUT: ClassVar[float] = WAIT_MAX_TIMEOUT
_WAIT_POLL_INTERVAL: ClassVar[float] = WAIT_POLL_INTERVAL
_WAIT_HEARTBEAT_INTERVAL: ClassVar[float] = WAIT_HEARTBEAT_INTERVAL
def wait_for_workstream(
self,
@@ -721,12 +765,7 @@ class CoordinatorClient:
snaps: dict[str, dict[str, Any]] = {}
for wid in cleaned:
row = rows.get(wid)
if row is None:
snaps[wid] = {"state": "denied", "tokens": 0}
continue
is_self = wid == self._coord_ws_id
is_own_child = row.get("parent_ws_id") == self._coord_ws_id
if not (is_self or is_own_child):
if row is None or not self._row_in_own_subtree(wid, row):
snaps[wid] = {"state": "denied", "tokens": 0}
continue
snaps[wid] = {
@@ -760,54 +799,119 @@ class CoordinatorClient:
last_results: dict[str, dict[str, Any]] = {}
complete = False
while True:
results = _snapshot_all()
last_results = results
if progress_callback is not None:
try:
progress_callback(results, time.monotonic() - start)
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()]
# ``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
# already-terminal children. ws_ids absent from ``since_map``
# are ignored for the diff-exit check — they fall through to
# the normal mode='any' / mode='all' conditions below. This
# prevents a disjoint since-dict from exiting on tick one
# with complete=True (previous shape did, silently).
if since_map and any(
_diff_since(snap, since_map[wid])
for wid, snap in results.items()
if wid in since_map
):
complete = True
break
if mode == "any":
if any(real_terminal):
# 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
# wait on [A, B, C] wakes on any of A/B/C changing. Bus is
# optional so test fixtures that don't wire it fall back to the
# legacy ``time.sleep`` cadence with no behaviour change.
#
# **Defense-in-depth ownership filter**: ``_dispatch_child_event``
# fires ``bus.notify(ws_id)`` for every ws_id in *any* coord's
# 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
# 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
# :meth:`_row_in_own_subtree`.
try:
pre_rows = self._storage.get_workstreams_batch(cleaned)
except Exception:
log.debug("coord_client.wait.ownership_filter_failed", exc_info=True)
pre_rows = {wid: None for wid in cleaned}
own_subtree = [wid for wid in cleaned if self._row_in_own_subtree(wid, pre_rows.get(wid))]
bus = self._child_event_bus
wake_event = bus.register_waiter(own_subtree) if own_subtree else None
try:
while True:
# Clear BEFORE the storage snapshot to close the
# subscribe/check race: any ``notify`` between clear
# and the next ``wake_event.wait`` leaves the Event
# set, so the wait returns immediately and the loop
# re-snapshots without losing the wake-up.
if wake_event is not None:
wake_event.clear()
results = _snapshot_all()
last_results = results
if progress_callback is not None:
try:
progress_callback(results, time.monotonic() - start)
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()]
# ``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
# already-terminal children. ws_ids absent from ``since_map``
# are ignored for the diff-exit check — they fall through to
# the normal mode='any' / mode='all' conditions below. This
# prevents a disjoint since-dict from exiting on tick one
# with complete=True (previous shape did, silently).
if since_map and any(
_diff_since(snap, since_map[wid])
for wid, snap in results.items()
if wid in since_map
):
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):
if mode == "any":
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.
complete = True
break
remaining = deadline - time.monotonic()
if remaining <= 0:
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.
complete = True
break
remaining = deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(self._WAIT_POLL_INTERVAL, remaining))
if wake_event is not None:
# Block until a child state-change notify fires OR
# the heartbeat cap expires (so a stuck child still
# emits a periodic ``wait_progress`` for the
# sidebar UX). Heartbeat cap is the only timer —
# the bus is the wake source. See
# ``WAIT_HEARTBEAT_INTERVAL`` (module top) for the
# worst-case completion-latency rationale: 2 s is
# 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.
time.sleep(min(self._WAIT_HEARTBEAT_INTERVAL, remaining))
finally:
# Always unregister so a crash mid-wait can't leak the
# registration past one wait's lifetime. Bus discards
# empty buckets so long-lived buses don't accumulate dead
# keys after many waits. Unregister against the same
# ``own_subtree`` list the register call used — passing
# ``cleaned`` here would silently no-op for foreign ids
# but pass an unknown bucket to ``unregister_waiter``.
if wake_event is not None:
bus.unregister_waiter(own_subtree, wake_event)
# 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
@@ -816,7 +920,7 @@ class CoordinatorClient:
# 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
# sequential storage round-trips down to 4 batches, which lands
# inside the WAIT_POLL_INTERVAL the model already tolerates
# inside the WAIT_HEARTBEAT_INTERVAL the model already tolerates
# between ticks. Storage backends use SQLAlchemy with
# ``check_same_thread=False`` (SQLite) / a connection pool
# (Postgres), so concurrent reads from the worker pool are safe.
@@ -1174,21 +1278,29 @@ class CoordinatorClient:
allowed_tools: list[str] = [str(t) for t in allowed_full[:_SKILL_TOOLS_PROJECTION_CAP]]
if len(allowed_full) > _SKILL_TOOLS_PROJECTION_CAP:
allowed_tools.append(f"+{len(allowed_full) - _SKILL_TOOLS_PROJECTION_CAP} more")
skills.append(
{
"name": r.get("name") or "",
"category": r.get("category") or "",
"tags": tags,
"version": r.get("version") or "",
"description": r.get("description") or "",
"model": r.get("model") or "",
"enabled": bool(r.get("enabled")),
"risk_level": r.get("risk_level") or "",
"activation": r.get("activation") or "",
"kind": r["kind"],
"allowed_tools": allowed_tools,
}
)
skill_row: dict[str, Any] = {
"name": r.get("name") or "",
"category": r.get("category") or "",
"tags": tags,
"version": r.get("version") or "",
"description": r.get("description") or "",
"model": r.get("model") or "",
"enabled": bool(r.get("enabled")),
"risk_level": r.get("risk_level") or "",
"activation": r.get("activation") or "",
"kind": r["kind"],
}
# Omit ``allowed_tools`` when empty: an empty list reads as
# "no tools are usable by this skill" to a model that doesn't
# know the semantics, but the actual meaning is "no tools are
# pre-approved (auto-approve exemption list)". Real
# misdiagnosis happened in testing when a code-review skill
# with no auto-approve allowlist looked like it had been
# spawned with zero tool access. Dropping the key altogether
# when empty removes the ambiguity at the source.
if allowed_tools:
skill_row["allowed_tools"] = allowed_tools
skills.append(skill_row)
return {"skills": skills, "truncated": truncated}
# ------------------------------------------------------------------
@@ -1687,6 +1799,293 @@ def _serialize_verdicts(rows: list[Any]) -> list[dict[str, Any]]:
return out
# ---------------------------------------------------------------------------
# inspect_workstream — tiered output compression
# ---------------------------------------------------------------------------
#
# A coord doing a fan-out wave of inspect_workstream calls against
# tool-heavy children can blow the context budget on raw output alone
# (one child with a 100 KB bash result × N children). The previous
# safety net was ``_truncate_output``'s head+tail strategy, which
# silently drops *middle* messages — exactly the wrong shape for a
# coordinator trying to understand a child's trajectory (the LAST
# message tells the model what the child concluded; the FIRST sets
# the brief; the middle is the connective tissue).
#
# The three-tier degradation pattern matches the ``search`` tool's
# Tier-1/Tier-2/Tier-3 ladder at ``session.py:_format_search_results``.
# First tier whose serialized size fits the budget wins; the LLM
# learns which tier it got via the ``_tier`` field in the response
# (no API change to the coordinator tool).
#
# Budget chosen well under ``tool_truncation`` (typically 256 KB+) so
# the head+tail safety net never fires for inspect_workstream — that
# strategy silently drops middle messages, which is exactly the
# pathology this formatter exists to avoid.
_INSPECT_OUTPUT_BUDGET: int = 32_768
# Per-message head/tail snip when Tier 2 needs to compress content.
# Head dominates because the first ~600 chars of an assistant message
# usually contains the conclusion / direction; the tail is the
# follow-through. Tool results compress similarly: head shows what
# the tool was asked / what it found at the top; tail shows the final
# state / error suffix.
_INSPECT_MSG_CONTENT_HEAD: int = 600
_INSPECT_MSG_CONTENT_TAIL: int = 300
# Skeleton-tier preview length on the last assistant message. Single
# value because the skeleton wants ONE meaningful signal ("what did
# the child last say"), not a head/tail snip.
_INSPECT_SKELETON_LAST_PREVIEW: int = 400
# Snip lengths for tool-call ``function.arguments`` strings on
# assistant turns. Tighter than content snipping because tool calls
# often appear in clusters (10+ per turn for a fan-out) and the
# arguments JSON is dense — keep just enough to see what was invoked
# and the head of the args structure.
_INSPECT_TOOL_ARG_HEAD: int = 300
_INSPECT_TOOL_ARG_TAIL: int = 100
# Bytes ``_snip_head_tail`` reserves for the elision marker itself
# (``\n...[N chars elided]...\n``). A text shorter than
# ``head + tail + this margin`` passes through unsnipped — snipping
# would cost more bytes (the marker) than it saves.
_INSPECT_ELISION_MARGIN: int = 64
# Message-list trim ladder for the compact tier when per-message
# content snipping alone doesn't free enough budget. Each rung is
# ``(head_count, tail_count)`` — keep the first N + last M messages,
# elide the middle as ``{"_omitted": K}``. Tail-weighted because the
# last assistant turn carries the load-bearing "what did the child
# conclude" signal (same rationale as ``_inspect_skeleton``'s
# last-assistant preview). Tried in order; first rung whose
# serialized emission fits the budget wins. Mirrors the per-file
# sample ladder in ``_format_search_results`` at session.py:254.
_INSPECT_LIST_TRIM_LADDER: tuple[tuple[int, int], ...] = ((20, 30), (10, 20), (5, 10))
def _snip_head_tail(text: str, head: int, tail: int) -> str:
"""Head/tail snip with elision marker; passthrough when shorter than threshold."""
if not isinstance(text, str) or len(text) <= head + tail + _INSPECT_ELISION_MARGIN:
return text
elided = len(text) - head - tail
return text[:head] + f"\n...[{elided} chars elided]...\n" + text[-tail:]
def _compact_tool_calls(tool_calls: Any) -> Any:
"""Snip ``function.arguments`` on each tool-call entry; keep ``id`` and
``function.name`` verbatim.
OpenAI shape: ``[{"id": ..., "type": "function", "function":
{"name": ..., "arguments": "<json-string>"}}, ...]``. The
arguments string is the dominant size term on a fan-out turn that
issued many tool calls with multi-KB JSON arguments each;
preserving them verbatim re-opens the same size pressure the
compact tier is trying to relieve. Non-list / non-dict entries
pass through so a future shape change doesn't crash the formatter.
"""
if not isinstance(tool_calls, list):
return tool_calls
out: list[Any] = []
for call in tool_calls:
if not isinstance(call, dict):
out.append(call)
continue
compact_call: dict[str, Any] = {}
for k in ("id", "type"):
v = call.get(k)
if v:
compact_call[k] = v
func = call.get("function")
if isinstance(func, dict):
compact_func: dict[str, Any] = {}
name = func.get("name")
if name:
compact_func["name"] = name
args = func.get("arguments", "")
if args:
compact_func["arguments"] = _snip_head_tail(
args, _INSPECT_TOOL_ARG_HEAD, _INSPECT_TOOL_ARG_TAIL
)
compact_call["function"] = compact_func
out.append(compact_call)
return out
def _compact_message(msg: dict[str, Any]) -> dict[str, Any]:
"""Tier-2 per-message projection: keep role + identifier keys, snip content + tool_calls.
Tool-call linkage is the load-bearing "what happened" signal:
``tool_call_id`` on the result side matches an ``id`` in
``tool_calls`` on the issuing assistant turn. Stripping
``tool_calls`` (the pre-fix shape) left tool results dangling
against an invisible call the audit reader could see "bash
returned X" but not "the assistant asked for ``ls /tmp``". The
``arguments`` string is the size offender, so we snip it head/tail
rather than dropping the call entirely.
"""
content = msg.get("content", "")
snipped = _snip_head_tail(content, _INSPECT_MSG_CONTENT_HEAD, _INSPECT_MSG_CONTENT_TAIL)
compact: dict[str, Any] = {"role": msg.get("role"), "content": snipped}
# Tool-result linkage (result-side keys).
for k in ("tool_name", "tool_call_id", "name"):
v = msg.get(k)
if v:
compact[k] = v
# Tool-call request linkage (issuing-side list), snipped per-call.
tool_calls = msg.get("tool_calls")
if tool_calls:
compact["tool_calls"] = _compact_tool_calls(tool_calls)
return compact
def _inspect_skeleton(result: dict[str, Any]) -> dict[str, Any]:
"""Tier-3 fallback: state + counts + last assistant preview + terminal info.
Drops every message, keeping only aggregate signal: state, message
count, role distribution, verdict count + risk distribution, and a
short preview of the most recent assistant turn (the "what did this
child last say" signal). Terminal-state fields (``close_reason``,
``last_error``) and the ``live`` block pass through unchanged
because they're already small and load-bearing.
"""
messages = result.get("messages") or []
verdicts = result.get("verdicts") or []
role_counts: dict[str, int] = {}
for m in messages:
role = m.get("role") if isinstance(m, dict) else None
if role:
role_counts[role] = role_counts.get(role, 0) + 1
verdicts_by_risk: dict[str, int] = {}
for v in verdicts:
if isinstance(v, dict):
risk = v.get("risk_level") or "unknown"
verdicts_by_risk[risk] = verdicts_by_risk.get(risk, 0) + 1
last_preview = ""
for m in reversed(messages):
if not isinstance(m, dict) or m.get("role") != "assistant":
continue
c = m.get("content", "")
if isinstance(c, str) and c:
last_preview = c[:_INSPECT_SKELETON_LAST_PREVIEW]
if len(c) > _INSPECT_SKELETON_LAST_PREVIEW:
last_preview += "..."
break
skeleton: dict[str, Any] = {
# Storage row keys verbatim from ``get_workstreams_batch``
# (the projection backing ``get_workstream`` → ``inspect()``):
# ``ws_id``, ``skill_id``. No fallback to ``id`` / ``skill``
# — fail loud on storage column drift rather than silently
# emitting null.
"ws_id": result["ws_id"],
"state": result.get("state"),
"title": result.get("title"),
"skill": result["skill_id"],
"message_count": len(messages),
"roles": role_counts,
"verdict_count": len(verdicts),
"verdicts_by_risk": verdicts_by_risk,
"last_assistant_preview": last_preview,
"_tier": "skeleton",
"_tier_note": (
"Output exceeded the inspect_workstream budget at both full and compact "
"tiers; skeleton-only. Re-call with a smaller ``message_limit`` to fit "
"the compact tier, or read individual messages via the storage admin path."
),
}
for k in ("close_reason", "last_error", "live"):
v = result.get(k)
if v:
skeleton[k] = v
return skeleton
def _format_inspect_tiered(result: dict[str, Any], *, budget: int = _INSPECT_OUTPUT_BUDGET) -> str:
"""Serialize an ``inspect_workstream`` result with tiered degradation.
Tier 1 (full): every message verbatim used when the size fits.
Tier 2 (compact): per-message ``{role, head/tail-snipped content,
tool linkage, snipped tool_calls.arguments}`` for
every message, then a head+tail message-list trim
ladder when content snipping alone doesn't free
enough budget.
Tier 3 (skeleton): no messages counts + last assistant preview only.
First emission whose JSON serialization fits ``budget`` wins.
``_tier`` appears on every non-error emission so the coordinator
LLM (and any audit reader) can see which compression rung the
output landed on without inferring from length. Error-shape
results (missing or cross-tenant ws_id) bypass tiering entirely
they're already small and the ``error`` key signals the shape.
The intermediate Tier-2 list-trim rungs exist because content
snipping alone fails on workloads where many small messages
overflow the budget by sheer count (``message_limit=200`` × a few
hundred chars each). In that regime, dropping content-snipping
saves zero bytes per message, so without the list-trim ladder
Tier-2 produces output strictly larger than Tier-1 (added
``_tier_note``) and the formatter fell through to skeleton
losing every message when a head+tail message-list trim would
have preserved dozens. Mirrors the per-file sample ladder in
``_format_search_results`` (session.py:_SEARCH_TIER2_SAMPLE_LADDER).
"""
if "error" in result:
# Cross-tenant guard / not-found responses — pass through.
return json.dumps(result, default=str, separators=(",", ":"))
tier1 = {**result, "_tier": "full"}
out1 = json.dumps(tier1, default=str, separators=(",", ":"))
if len(out1) <= budget:
return out1
messages = result.get("messages") or []
compact_msgs = [_compact_message(m) if isinstance(m, dict) else m for m in messages]
tier2_note_full = (
"Output exceeded the inspect_workstream budget at the full tier; messages "
"are head/tail-snipped at "
f"{_INSPECT_MSG_CONTENT_HEAD}/{_INSPECT_MSG_CONTENT_TAIL} chars. Re-call "
"with a smaller ``message_limit`` for a tighter tail, or include_provider_"
"content=False if it was on."
)
tier2 = {
**result,
"messages": compact_msgs,
"_tier": "compact",
"_tier_note": tier2_note_full,
}
out2 = json.dumps(tier2, default=str, separators=(",", ":"))
if len(out2) <= budget:
return out2
# Tier-2 list-trim ladder: keep head N + tail M, elide the middle.
# Tail-weighted because the recent turns carry the load-bearing
# signal ("what did the child conclude") — same reason
# ``_inspect_skeleton`` keeps a last-assistant preview rather than
# a first-user preview.
total = len(compact_msgs)
for head_n, tail_n in _INSPECT_LIST_TRIM_LADDER:
if head_n + tail_n >= total:
# Rung doesn't actually trim — would re-emit Tier-2 verbatim.
continue
omitted = total - head_n - tail_n
trimmed: list[Any] = (
compact_msgs[:head_n] + [{"_omitted": omitted}] + compact_msgs[-tail_n:]
)
tier2_trim_note = (
f"Output exceeded the inspect_workstream budget at the compact tier; "
f"keeping first {head_n} + last {tail_n} of {total} messages, eliding "
f"{omitted} middle messages. Re-call with a smaller ``message_limit`` "
"to fit the full compact tier."
)
tier2_trim = {
**result,
"messages": trimmed,
"_tier": "compact",
"_tier_note": tier2_trim_note,
}
out2_trim = json.dumps(tier2_trim, default=str, separators=(",", ":"))
if len(out2_trim) <= budget:
return out2_trim
skeleton = _inspect_skeleton(result)
return json.dumps(skeleton, default=str, separators=(",", ":"))
# ---------------------------------------------------------------------------
# wait_for_workstream — last-message extraction
# ---------------------------------------------------------------------------
+362
View File
@@ -0,0 +1,362 @@
"""Console-side multiplexer for PostgreSQL ``LISTEN``/``NOTIFY`` events.
Holds a single dedicated listen connection (via :meth:`StorageBackend.listen`),
drains it on a listener thread, and fans notifications out to per-channel
handlers on a dedicated dispatch thread so a slow handler doesn't back up
the connection.
Consumers register at construction time by passing their channel in
:attr:`channels`, then call :meth:`subscribe` to attach a handler.
Registering an undeclared channel raises the construction list is the
single source of truth so wire-in is explicit (each future consumer
touches the dispatcher construction call site at
``turnstone/console/server.py::main`` to add its channel).
On connection loss the listener wakes its handlers with a synthetic
``Notify(channel, payload="reconcile", pid=0)`` so every consumer
re-reads the underlying rows; their normal "reconcile on any wake-up"
code path covers both real notifications and reconnect recovery
identically.
"""
from __future__ import annotations
import contextlib
import queue
import threading
import time
from typing import TYPE_CHECKING
from turnstone.core.log import get_logger
from turnstone.core.storage._notify import Notify, NotifyConnectionError
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from turnstone.core.storage._protocol import StorageBackend
log = get_logger(__name__)
# Backoff (seconds) between reconnect attempts after :class:`NotifyConnectionError`.
# Doubles each failure, capped at the max — long enough that a Postgres outage
# doesn't burn CPU on reconnect spins, short enough that recovery is fast.
_RECONNECT_BACKOFF_INITIAL: float = 1.0
_RECONNECT_BACKOFF_MAX: float = 30.0
# Poll cadence on the listener thread. Short enough that ``stop`` lands
# promptly without joining a long-blocked notifies() call; long enough
# that we don't burn CPU on empty polls.
_LISTENER_POLL_TIMEOUT: float = 1.0
# Cap on the inter-thread dispatch queue. Drops oldest if a slow handler
# falls behind (logs once per drop bucket). Sized larger than the expected
# steady-state notification rate (services trigger fires only on
# register/restart/deregister — order of hundreds per hour at the 100-node
# design ceiling).
_DISPATCH_QUEUE_MAX: int = 1024
class NotifyDispatcher:
"""Holds the dedicated listen connection and fans events to handlers.
Lifecycle: construct with the declared channel list, attach
handlers via :meth:`subscribe`, then call :meth:`start`. :meth:`stop`
closes the connection and joins the worker threads. Idempotent in
both directions so console teardown can call stop unconditionally.
"""
def __init__(self, storage: StorageBackend, channels: Iterable[str]) -> None:
ch_list = [str(c) for c in channels if c]
if not ch_list:
msg = "NotifyDispatcher requires at least one declared channel"
raise ValueError(msg)
self._storage = storage
self._channels: list[str] = list(dict.fromkeys(ch_list)) # de-dupe, preserve order
self._handlers: dict[str, list[Callable[[Notify], None]]] = {
ch: [] for ch in self._channels
}
self._handlers_lock = threading.Lock()
self._lifecycle_lock = threading.Lock()
self._started = False
self._stopping = threading.Event()
self._listener_thread: threading.Thread | None = None
self._dispatch_thread: threading.Thread | None = None
self._dispatch_queue: queue.Queue[Notify | None] = queue.Queue(maxsize=_DISPATCH_QUEUE_MAX)
self._drop_count = 0
# Set inside :meth:`_listener_loop` after each successful
# ``storage.listen`` open; cleared on disconnect. Callers use
# :meth:`wait_until_ready` after :meth:`start` to block until the
# listener is actually listening (matters when the next caller
# action is a ``notify`` whose delivery requires the LISTEN to
# already be in place — e.g. tests, or any startup-path traffic
# that should be reactive from the first event).
self._listener_ready = threading.Event()
@property
def channels(self) -> list[str]:
"""Snapshot copy of declared channels."""
return list(self._channels)
def subscribe(self, channel: str, handler: Callable[[Notify], None]) -> Callable[[], None]:
"""Attach ``handler`` to ``channel``; return an unsubscribe callable.
Safe to call before or after :meth:`start`. Raises if the
channel was not declared at construction time the channel
list is fixed so the dispatcher knows up-front which LISTENs
to issue (consumers added in follow-up PRs touch the
construction call site).
"""
if channel not in self._handlers:
msg = (
f"channel {channel!r} not declared at construction; "
f"declared channels: {sorted(self._handlers)}"
)
raise ValueError(msg)
with self._handlers_lock:
self._handlers[channel].append(handler)
def _unsubscribe() -> None:
with self._handlers_lock, contextlib.suppress(ValueError):
self._handlers[channel].remove(handler)
return _unsubscribe
def start(self) -> None:
"""Open the listen stream and start the listener + dispatch threads.
Idempotent repeat calls log a debug line and return without
spawning a second listener.
"""
with self._lifecycle_lock:
if self._started:
log.debug("notify_dispatcher.start_noop_already_started")
return
self._started = True
self._stopping.clear()
# Clear ready so a stop/start cycle's wait_until_ready only
# returns True after the new listener has actually opened.
self._listener_ready.clear()
self._listener_thread = threading.Thread(
target=self._listener_loop,
name="notify-dispatcher-listener",
daemon=True,
)
self._dispatch_thread = threading.Thread(
target=self._dispatch_loop,
name="notify-dispatcher-dispatch",
daemon=True,
)
self._listener_thread.start()
self._dispatch_thread.start()
log.info(
"notify_dispatcher.started",
channels=self._channels,
)
def wait_until_ready(self, timeout: float = 5.0) -> bool:
"""Block until the listener has opened its stream, or ``timeout`` elapses.
Returns ``True`` when the listener is ready (``LISTEN`` issued
for every declared channel on PG; subscriber queues registered
on SQLite), ``False`` on timeout. Cleared automatically on
disconnect call again after a reconnect to wait for the next
successful reopen.
Doesn't replace :meth:`start` — call ``start()`` first, then
``wait_until_ready()`` for the explicit sync point. Production
startup typically doesn't need this (the first real event tends
to arrive well after the listener is up); tests use it to close
the start-vs-notify race window.
"""
return self._listener_ready.wait(timeout=timeout)
def stop(self, timeout: float = 5.0) -> None:
"""Signal shutdown and join the worker threads.
Idempotent safe to call multiple times. Workers exit on the
next iteration of their poll loops; :meth:`stop` blocks up to
``timeout`` seconds per thread before giving up (the threads are
daemons so the process can exit regardless).
"""
with self._lifecycle_lock:
if not self._started:
return
self._stopping.set()
listener = self._listener_thread
dispatcher = self._dispatch_thread
# Sentinel wakes the dispatch loop out of queue.get().
with contextlib.suppress(queue.Full):
self._dispatch_queue.put_nowait(None)
if listener is not None:
listener.join(timeout=timeout)
if dispatcher is not None:
dispatcher.join(timeout=timeout)
with self._lifecycle_lock:
self._listener_thread = None
self._dispatch_thread = None
self._started = False
log.info("notify_dispatcher.stopped")
# ------------------------------------------------------------------
# Internal threading
# ------------------------------------------------------------------
def _listener_loop(self) -> None:
"""Drain the storage stream onto the dispatch queue, reconnecting on loss.
After any disconnect whether surfaced through the stream's
:class:`NotifyConnectionError` (post-open ``poll`` failure) or
through the generic exception path (``psycopg.connect`` /
initial ``LISTEN`` execute failures during reopen, which are
NOT wrapped by the stream) the loop sets a ``reconcile_pending``
flag, waits the backoff, then enqueues one synthetic ``reconcile``
notify per channel ONLY after the next stream successfully
reopens. Handlers see the synthetic notify and re-read the
relevant rows on the same code path they use for any real event,
closing the missed-notification window regardless of which
exception type caused the disconnect.
"""
backoff = _RECONNECT_BACKOFF_INITIAL
reconcile_pending = False
while not self._stopping.is_set():
try:
with self._storage.listen(self._channels) as stream:
log.debug(
"notify_dispatcher.stream_open",
channels=self._channels,
)
# Stream is open — reset backoff for the next outage
# and flush any pending reconcile so consumers see a
# wake-up against a now-live DB.
backoff = _RECONNECT_BACKOFF_INITIAL
if reconcile_pending:
self._synthesize_reconcile()
reconcile_pending = False
# Signal ``wait_until_ready`` callers that LISTEN is
# in place (PG) / subscriber queues are bound
# (SQLite). Must come AFTER the synthesize so any
# post-reconnect reconcile reaches handlers before
# the caller assumes "fresh notifies will deliver".
self._listener_ready.set()
while not self._stopping.is_set():
batch = stream.poll(_LISTENER_POLL_TIMEOUT)
for n in batch:
self._enqueue(n)
except NotifyConnectionError as exc:
if self._stopping.is_set():
return
self._listener_ready.clear()
log.warning(
"notify_dispatcher.connection_lost",
error=str(exc),
backoff_seconds=backoff,
)
reconcile_pending = True
if self._stopping.wait(backoff):
return
backoff = min(backoff * 2.0, _RECONNECT_BACKOFF_MAX)
except Exception:
if self._stopping.is_set():
return
self._listener_ready.clear()
log.exception("notify_dispatcher.listener_unexpected_error")
reconcile_pending = True
if self._stopping.wait(backoff):
return
backoff = min(backoff * 2.0, _RECONNECT_BACKOFF_MAX)
log.debug("notify_dispatcher.listener_exiting")
def _synthesize_reconcile(self) -> None:
"""Push one synthetic ``reconcile`` notify per channel on reconnect.
Reconcile-on-wake is the same logic handlers run for any real
notification, so a single synthetic event per channel covers
any notifications missed during the connection-loss window.
"""
for ch in self._channels:
self._enqueue(Notify(channel=ch, payload="reconcile", pid=0))
def _enqueue(self, notify: Notify) -> None:
"""Put a notify on the dispatch queue, dropping oldest on overflow."""
try:
self._dispatch_queue.put_nowait(notify)
except queue.Full:
# Drop oldest to make room — a slow handler shouldn't be able
# to silently block the listener thread. Log once per power
# of two so a sustained backpressure problem shows up
# in logs without flooding.
self._drop_count += 1
if self._drop_count & (self._drop_count - 1) == 0:
log.warning(
"notify_dispatcher.dispatch_queue_full_dropping_oldest",
drops_total=self._drop_count,
channel=notify.channel,
)
with contextlib.suppress(queue.Empty):
self._dispatch_queue.get_nowait()
with contextlib.suppress(queue.Full):
self._dispatch_queue.put_nowait(notify)
def _dispatch_loop(self) -> None:
"""Pull notifies off the queue and invoke handlers per channel.
Notifies queued on the same channel coalesce per dispatch batch:
after blocking ``get()`` returns one notify, the loop drains
whatever else is already queued and collapses to one
``per-channel`` notify before invoking handlers. The payload is
signal-only by design (handlers reconcile by re-reading the
underlying rows), so N same-channel notifies have the same
observable effect as one coalescing turns an N-node deploy
burst into a single ``_discover_nodes`` per channel instead of N.
Each handler runs under exception suppression so one buggy
consumer can't take down the dispatch thread.
"""
while not self._stopping.is_set():
try:
first = self._dispatch_queue.get(timeout=_LISTENER_POLL_TIMEOUT)
except queue.Empty:
continue
if first is None:
# Sentinel from :meth:`stop`.
return
# Coalesce by channel: keep the most recent payload per
# channel from this drain batch. Drops a stop sentinel
# silently — the next loop iteration will see _stopping set
# and exit anyway, so we don't need to re-queue the sentinel.
per_channel: dict[str, Notify] = {first.channel: first}
stop_seen = False
while True:
try:
nxt = self._dispatch_queue.get_nowait()
except queue.Empty:
break
if nxt is None:
stop_seen = True
continue
per_channel[nxt.channel] = nxt
for notify in per_channel.values():
with self._handlers_lock:
handlers = list(self._handlers.get(notify.channel, ()))
for handler in handlers:
t0 = time.monotonic()
try:
handler(notify)
except Exception:
log.exception(
"notify_dispatcher.handler_failed",
channel=notify.channel,
)
else:
elapsed_ms = (time.monotonic() - t0) * 1000.0
if elapsed_ms > 100.0:
log.debug(
"notify_dispatcher.handler_slow",
channel=notify.channel,
elapsed_ms=round(elapsed_ms, 1),
)
if stop_seen:
return
log.debug("notify_dispatcher.dispatch_exiting")
+6
View File
@@ -319,6 +319,12 @@ class TaskScheduler:
user_id=task.get("created_by", ""),
skill=task.get("skill", ""),
notify_targets=task.get("notify_targets", "[]"),
# Mark the resulting ChatSession as non-interactive-for-
# consent so OAuth-MCP errors get persisted to
# ``mcp_pending_consent`` for later dashboard surfacing,
# rather than relying on an in-flight SSE redirect the
# absent user can't complete.
client_type="scheduled",
)
ws_id = resp.ws_id
except Exception:
+338 -18
View File
@@ -1652,6 +1652,27 @@ async def mcp_oauth_revoke_connection(request: Request) -> Response:
return await handle_mcp_oauth_revoke_connection(request)
async def mcp_oauth_list_pending(request: Request) -> Response:
"""GET /v1/api/mcp/oauth/pending — list deferred-consent records (Phase 9)."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_list_pending
return await handle_mcp_oauth_list_pending(request)
async def mcp_oauth_clear_pending(request: Request) -> Response:
"""DELETE /v1/api/mcp/oauth/pending/{server_name} — dismiss a deferred-consent record."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_clear_pending
return await handle_mcp_oauth_clear_pending(request)
async def mcp_oauth_clear_all_pending(request: Request) -> Response:
"""DELETE /v1/api/mcp/oauth/pending — bulk-dismiss deferred-consent records."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_clear_all_pending
return await handle_mcp_oauth_clear_all_pending(request)
# ---------------------------------------------------------------------------
# Route handlers — available models (lightweight, no admin permission)
# ---------------------------------------------------------------------------
@@ -2636,10 +2657,67 @@ async def proxy_shared_static(request: Request) -> Response:
return JSONResponse({"error": "Node unreachable"}, status_code=502)
# Auth endpoints the console handles locally instead of forwarding to
# the upstream node. Single source of truth for the dispatch table and
# the path set used by both the 405 short-circuit and the
# test parametrize, so a new entry can't drift between code and tests.
#
# Values are handler NAMES (strings) rather than function references.
# ``proxy_api`` resolves them via ``globals()`` at call time so test
# ``patch("turnstone.console.server.auth_login")`` is observed; a dict
# of refs would capture the original function at module load.
_PROXY_AUTH_LOCAL_HANDLERS: dict[tuple[str, str], str] = {
("POST", "auth/login"): "auth_login",
("POST", "auth/logout"): "auth_logout",
("POST", "auth/setup"): "auth_setup",
("POST", "auth/refresh"): "auth_refresh",
("GET", "auth/status"): "auth_status",
("GET", "auth/whoami"): "auth_whoami",
("GET", "auth/oidc/authorize"): "oidc_authorize",
("GET", "auth/oidc/callback"): "oidc_callback",
}
_PROXY_AUTH_LOCAL_PATHS: frozenset[str] = frozenset(path for _, path in _PROXY_AUTH_LOCAL_HANDLERS)
async def proxy_api(request: Request) -> Response:
"""Proxy API requests to target node. Detects SSE vs regular."""
"""Proxy API requests to target node, with two exceptions handled in-process:
1. ``auth/*`` endpoints in ``_PROXY_AUTH_LOCAL_HANDLERS`` are
dispatched to the console's own auth handlers so JWTs carry
``JWT_AUD_CONSOLE`` and Set-Cookie lands on the console origin.
Forwarding upstream would mint ``JWT_AUD_SERVER`` tokens that the
console's ``AuthMiddleware`` rejects on the next proxied call,
locking the user out of the proxied UI and ``_proxy_post``
drops Set-Cookie when forwarding anyway. ``refresh`` and
``whoami`` are intentionally NOT in ``PUBLIC_PATHS`` (caller must
still hold a valid cookie); local dispatch is about cookie-origin
and audience, not public access.
2. SSE endpoints (per-ws + global events) stream via ``_proxy_sse``.
Everything else is forwarded to ``server_url`` via ``_proxy_post`` /
``_proxy_get``.
"""
node_id = request.path_params["node_id"]
path = request.path_params["path"]
handler_name = _PROXY_AUTH_LOCAL_HANDLERS.get((request.method, path))
if handler_name is not None:
# Resolve via ``globals()`` so ``patch("...auth_login")`` in
# tests is observed. A direct function-ref dict would have
# captured the original at module load.
handler = globals()[handler_name]
return await handler(request) # type: ignore[no-any-return]
# Path matches a local-dispatch auth endpoint but the method does not:
# short-circuit with 405 so the request can't fall through to
# ``_proxy_post`` / ``_proxy_get`` and reach the upstream
# authenticated as the console's service token
# (``_proxy_auth_headers`` falls back to the service identity when
# there's no user context). Harmless today because every upstream
# auth route 405s on the wrong method too, but kept tight so a
# future upstream patch can't widen the surface by accident.
if path in _PROXY_AUTH_LOCAL_PATHS:
return JSONResponse({"error": "Method not allowed"}, status_code=405)
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
@@ -4140,6 +4218,7 @@ def _coord_idle_cleanup_thread(
mgr: SessionManager,
timeout_sec: float,
stop_event: threading.Event | None = None,
min_sweep_interval: float = 5.0,
) -> None:
"""Periodically reap idle + DB-orphan coordinator workstreams.
@@ -4150,7 +4229,7 @@ def _coord_idle_cleanup_thread(
and which aren't currently loaded. The latter pass catches coords left
behind by prior console process incarnations.
Runs an initial sweep BEFORE the first sleep so cold-start orphans are
Runs an initial sweep BEFORE the first wait so cold-start orphans are
reaped immediately rather than waiting one ``check_every`` interval (~30
min on default 2h timeout). This intentionally diverges from the regular
server pattern, which has no initial sweep the regular server runs
@@ -4158,26 +4237,90 @@ def _coord_idle_cleanup_thread(
is a small fixed-size cache where orphans dominate the row count after
a cold boot.
Wait shape: subscribes a callback to ``mgr._state_subscribers`` that
sets a ``tick_now`` event; the loop blocks on ``tick_now.wait(check_every)``
so any workstream state-change wakes the sweeper without waiting a
full check interval, AND the timeout still fires the periodic sweep
even when no activity happens (catching the DB-orphan-only case).
Net: blocked most of the time instead of repeating storage scans.
``min_sweep_interval`` is the hard floor between successive
``close_idle`` calls (default 5 s) without it, sustained
state-change activity (each turn typically fires
thinking/running/attention/idle on the coord SessionManager) would
cause every ``tick_now.set`` mid-sweep to leave the next ``wait``
returning immediately, and the loop would tight-spin ``close_idle``
at the rate of its own DB latency (~20-50 calls/sec). The floor
bounds DB-call traffic at ``1 / min_sweep_interval`` per second
under any external activity while still letting a quiet system
fire on every state-change wake-up. Tests inject 0.0 to keep the
suite fast.
Default 5 s is a 6x improvement on the pre-refactor fixed 30 s
cadence while bounding DB-call traffic at ~0.2 calls/sec under
sustained activity an order of magnitude below ``close_idle``'s
DB-latency budget, but tight enough that idle-row reaping still
feels prompt to a human watching the sidebar. Tunable post-merge
if profiling shows close_idle latency dominates the cadence.
``stop_event`` is for tests when set, the thread exits cleanly after
the next loop check. Production callers pass ``None`` (the daemon is
process-lifetime).
"""
check_every = min(300.0, timeout_sec / 4)
# Initial sweep — runs once before entering the sleep loop.
tick_now = threading.Event()
def _on_state_change(_ws_id: str, _state: Any) -> None:
# Any workstream state-change resets the idle clock for that
# ws AND may make a different ws newly-eligible (close-idle
# pass 2 evaluates DB rows by timestamp). Cheap signal, full
# re-evaluation deferred to the next loop iteration.
tick_now.set()
mgr.subscribe_to_state(_on_state_change)
try:
mgr.close_idle(timeout_sec)
except Exception:
log.debug("console.coord_idle_cleanup_initial_failed", exc_info=True)
while True:
if stop_event is not None and stop_event.is_set():
return
time.sleep(check_every)
if stop_event is not None and stop_event.is_set():
return
# Initial sweep — runs once before entering the wait loop.
# ``tick_now`` is intentionally not cleared here: any
# state-change event that arrives between subscribe and the
# first ``wait`` should fire close_idle immediately, not be
# discarded.
try:
mgr.close_idle(timeout_sec)
except Exception:
log.debug("console.coord_idle_cleanup_failed", exc_info=True)
log.debug("console.coord_idle_cleanup_initial_failed", exc_info=True)
last_sweep_at = time.monotonic()
while True:
if stop_event is not None and stop_event.is_set():
return
tick_now.wait(check_every)
if stop_event is not None and stop_event.is_set():
return
# Clear BEFORE the cadence floor so any state-change event
# arriving during the cooldown (or during the close_idle
# below) leaves ``tick_now`` set — the next loop iteration
# then re-enters ``wait`` already-set and re-evaluates
# promptly. close_idle is idempotent so a spurious extra
# tick is just one redundant scan.
tick_now.clear()
# Cadence floor — see docstring for the tight-spin
# hazard rationale. Cooldown uses ``stop_event.wait``
# (not ``time.sleep``) so the test stop hook still
# terminates promptly during the cooldown window.
since_last = time.monotonic() - last_sweep_at
if since_last < min_sweep_interval:
gap = min_sweep_interval - since_last
if stop_event is not None:
if stop_event.wait(gap):
return
else:
time.sleep(gap)
try:
mgr.close_idle(timeout_sec)
except Exception:
log.debug("console.coord_idle_cleanup_failed", exc_info=True)
last_sweep_at = time.monotonic()
finally:
mgr.unsubscribe_from_state(_on_state_change)
# Guards concurrent attempts to bootstrap the coord subsystem from the
@@ -4239,12 +4382,22 @@ def _bootstrap_coord_subsystem(
def _token_factory() -> str:
return tm.token
# ``coord_adapter`` is bound later in this same
# ``_bootstrap_coord_subsystem`` call, after the adapter and
# manager are constructed but before any session is created
# — so this factory is *defined* before the adapter exists but
# only ever *called* after it does. The free-variable lookup
# at call time resolves to the adapter built in this same
# bootstrap pass, giving the client a handle to the in-process
# wakeup bus the dispatch sink notifies on every child
# state-change event.
return CoordinatorClient(
console_base_url=console_bind_url,
storage=storage,
token_factory=_token_factory,
coord_ws_id=ws_id,
user_id=user_id,
child_event_bus=coord_adapter.child_event_bus,
)
# Pre-compute config-derived integers BEFORE any thread starts so a
@@ -4770,6 +4923,12 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
await close_oidc_state(app.state)
app.state.collector.stop()
# Stop the dispatcher after the collector — collector.stop() drops its
# subscription, so the dispatcher's dispatch thread won't fire into a
# half-torn-down collector during shutdown.
notify_dispatcher = getattr(app.state, "notify_dispatcher", None)
if notify_dispatcher is not None:
notify_dispatcher.stop()
audit_exec_shutdown = getattr(app.state, "audit_executor", None)
if audit_exec_shutdown is not None:
_set_audit_executor(None)
@@ -8680,10 +8839,19 @@ def _mask_mcp_secrets(server: dict[str, Any], reveal: bool = False) -> dict[str,
def _mcp_server_to_detail(
server: dict[str, Any],
node_statuses: dict[str, dict[str, Any]] | None = None,
consented_users_count: int | None = None,
) -> dict[str, Any]:
"""Convert a storage dict to a McpServerDetail-shaped dict."""
"""Convert a storage dict to a McpServerDetail-shaped dict.
*consented_users_count* is the Phase 9 admin pill data distinct
non-expired tokens issued for this ``(server_name)``. Omitted
(``None``) when the row's ``auth_type`` is not ``oauth_user``, so
static / none rows don't carry an irrelevant ``0``.
"""
d = dict(server)
d["status"] = node_statuses or {}
if consented_users_count is not None:
d["consented_users_count"] = consented_users_count
return d
@@ -8734,8 +8902,31 @@ async def admin_list_mcp_servers(request: Request) -> JSONResponse:
reveal = str(request.query_params.get("reveal", "")).lower() in ("true", "1")
servers = storage.list_mcp_servers()
# Collect live status from all nodes
node_statuses = await _collect_mcp_status(request)
# Phase 9: bulk-aggregate consented-users-count across all oauth_user
# rows in a single GROUP BY query (rather than N per-row sync DB
# round-trips inside this async handler). Run in parallel with the
# cross-node HTTP status fan-out below — neither has a data
# dependency on the other, so awaiting them sequentially would
# stack the DB latency on top of the fan-out latency. Skipped
# entirely when no row is oauth_user so static-only installs
# exercise zero new storage queries.
has_oauth_user = any(s.get("auth_type") == "oauth_user" for s in servers)
status_task: asyncio.Task[dict[str, dict[str, dict[str, Any]]]] = asyncio.create_task(
_collect_mcp_status(request)
)
count_task: asyncio.Task[dict[str, int]] | None = (
asyncio.create_task(asyncio.to_thread(storage.count_mcp_consented_users_grouped_by_server))
if has_oauth_user
else None
)
node_statuses = await status_task
consent_counts: dict[str, int] = {}
if count_task is not None:
try:
consent_counts = await count_task
except Exception:
log.debug("admin.mcp_consented_users_bulk_count_failed", exc_info=True)
db_names: set[str] = set()
result = []
@@ -8747,8 +8938,14 @@ async def admin_list_mcp_servers(request: Request) -> JSONResponse:
status = node_servers.get(s["name"])
if status:
per_node[node_id] = status
# Phase 9: surface the consented-users-count pill for
# oauth_user rows. Aggregate was pre-computed above with a
# single bulk GROUP BY query; we just look up here.
consent_count: int | None = None
if s.get("auth_type") == "oauth_user":
consent_count = consent_counts.get(s["name"], 0)
s = _mask_mcp_secrets(s, reveal)
result.append(_mcp_server_to_detail(s, per_node))
result.append(_mcp_server_to_detail(s, per_node, consent_count))
# Merge config-sourced servers visible on nodes but not in DB
config_names: set[str] = set()
@@ -9389,6 +9586,92 @@ async def admin_mcp_reconnect_one(request: Request) -> JSONResponse:
return await _admin_mcp_action(request, "reconnect")
async def admin_mcp_bulk_revoke(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers/{name}/bulk-revoke — clear every user's token (Phase 9).
Admin-side counterpart to the per-user
``DELETE /v1/api/mcp/oauth/connections/{server_name}`` revoke that
shipped in Phase 8. Used to drop orphaned tokens after an
``auth_type`` transition (oauth_user static) or after rotating
the configured OAuth client.
Authoritative local delete via
:meth:`StorageBackend.delete_mcp_oauth_rows_by_server_name`
purges both ``mcp_user_tokens`` and ``mcp_oauth_pending`` rows for
the named server. Upstream RFC 7009 revoke is intentionally NOT
attempted in bulk (would require per-row decrypt + N upstream HTTP
calls); operators who need upstream cleanup should use the per-
user revoke endpoint or let tokens expire naturally. The audit
detail records ``upstream_revoke_outcome="bulk_admin_no_upstream"``
so the deferral is visible.
Pool eviction is NOT performed by this handler. Per-user revoke
has a per-(user, server) eviction primitive
(``MCPClientManager.evict_user_session``); bulk-revoke would need a
per-server iteration over consented users that no current primitive
supports. Stale in-flight sessions surface as a per-user 401 on
the next dispatch, which refreshes through the now-empty token row
and emits ``mcp_consent_required`` the documented v1 fallback.
See :func:`turnstone.core.mcp_client._dispatch_pool` retry path.
"""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
name = request.path_params.get("name", "").strip()
if not name or "__" in name:
return JSONResponse({"error": "invalid server name"}, status_code=400)
existing = storage.get_mcp_server_by_name(name)
if existing is None:
return JSONResponse({"error": "No such server"}, status_code=404)
if existing.get("auth_type") != "oauth_user":
return JSONResponse(
{"error": "bulk-revoke is only valid for auth_type=oauth_user servers"},
status_code=400,
)
target_id = existing.get("server_id", name)
consented_before = 0
try:
consented_before = storage.count_mcp_consented_users_by_server(name)
except Exception:
log.debug("admin.mcp_bulk_revoke_pre_count_failed server=%s", name, exc_info=True)
deleted = storage.delete_mcp_oauth_rows_by_server_name(name)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"mcp_server.oauth.bulk_revoked",
"mcp_server",
target_id,
{
"name": name,
"rows_deleted": deleted,
"consented_users_before": consented_before,
"upstream_revoke_outcome": "bulk_admin_no_upstream",
},
ip,
)
return JSONResponse(
{
"status": "ok",
"rows_deleted": deleted,
"consented_users_before": consented_before,
}
)
async def admin_import_mcp_config(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers/import — import from pasted JSON config."""
import uuid
@@ -11882,6 +12165,7 @@ def create_app(
console_url: str = "",
router: ConsoleRouter | None = None,
console_metrics: ConsoleMetrics | None = None,
notify_dispatcher: Any = None,
) -> Starlette:
"""Build the Starlette ASGI application for the console dashboard."""
_spec = build_console_spec()
@@ -12108,6 +12392,17 @@ def create_app(
mcp_oauth_revoke_connection,
methods=["DELETE"],
),
Route("/api/mcp/oauth/pending", mcp_oauth_list_pending),
Route(
"/api/mcp/oauth/pending",
mcp_oauth_clear_all_pending,
methods=["DELETE"],
),
Route(
"/api/mcp/oauth/pending/{server_name}",
mcp_oauth_clear_pending,
methods=["DELETE"],
),
Route("/api/admin/users", admin_list_users),
Route("/api/admin/users", admin_create_user, methods=["POST"]),
Route("/api/admin/users/{user_id}", admin_delete_user, methods=["DELETE"]),
@@ -12293,6 +12588,11 @@ def create_app(
admin_mcp_reconnect_one,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{name}/bulk-revoke",
admin_mcp_bulk_revoke,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{server_id}",
admin_get_mcp_server,
@@ -12508,6 +12808,7 @@ def create_app(
lifespan=_lifespan,
)
app.state.collector = collector
app.state.notify_dispatcher = notify_dispatcher
app.state.jwt_secret = jwt_secret
app.state.auth_storage = auth_storage
app.state.proxy_token_mgr = proxy_token_mgr
@@ -12603,7 +12904,7 @@ def main() -> None:
from turnstone.core.config import add_config_arg, apply_config
add_config_arg(parser)
apply_config(parser, ["console", "auth"])
apply_config(parser, ["console", "auth", "database"])
args = parser.parse_args()
from turnstone.core.log import configure_logging_from_args
@@ -12622,6 +12923,13 @@ def main() -> None:
db_backend = os.environ.get("TURNSTONE_DB_BACKEND", "sqlite")
db_url = os.environ.get("TURNSTONE_DB_URL", "")
db_path = os.environ.get("TURNSTONE_DB_PATH", "")
# Optional dedicated LISTEN URL — config.toml ``[database] listen_url``
# (lifted onto args by ``apply_config``) wins over env, and an empty
# value falls through to the main DB URL inside the storage layer.
# Only used by the ``NotifyDispatcher``; ignored on SQLite.
db_listen_url = getattr(args, "db_listen_url", None) or os.environ.get(
"TURNSTONE_DB_LISTEN_URL", ""
)
auth_storage = init_storage(
db_backend,
path=db_path,
@@ -12630,6 +12938,7 @@ def main() -> None:
sslrootcert=os.environ.get("TURNSTONE_DB_SSLROOTCERT", ""),
sslcert=os.environ.get("TURNSTONE_DB_SSLCERT", ""),
sslkey=os.environ.get("TURNSTONE_DB_SSLKEY", ""),
listen_url=db_listen_url,
)
except Exception:
log.info("Console storage not available — admin API disabled, JWT-only auth")
@@ -12659,11 +12968,21 @@ def main() -> None:
router = ConsoleRouter(storage=auth_storage)
console_metrics = ConsoleMetrics()
# NotifyDispatcher multiplexes the dedicated LISTEN connection for all
# console-side consumers. Currently one channel: ``services`` for
# reactive node discovery. Followup PRs (ConfigStore live reload,
# scheduler immediate dispatch) add additional channels here.
from turnstone.console.notify_dispatcher import NotifyDispatcher
notify_dispatcher = NotifyDispatcher(auth_storage, channels=["services"])
notify_dispatcher.start()
collector = ClusterCollector(
storage=auth_storage,
token_manager=collector_token_mgr,
router=router,
console_metrics=console_metrics,
notify_dispatcher=notify_dispatcher,
)
collector.start()
@@ -12755,6 +13074,7 @@ def main() -> None:
console_url=console_url,
router=router,
console_metrics=console_metrics,
notify_dispatcher=notify_dispatcher,
)
log.info("Console starting on %s", console_url)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -324,7 +324,7 @@
}
const body = document.createElement("div");
body.className = "msg-body";
body.innerHTML = html;
setSafeHtml(body, html);
el.appendChild(body);
messagesEl.appendChild(el);
_scheduleScroll();
@@ -336,10 +336,10 @@
}
// User-message bubble with attachment-pill cluster appended below
// the text. Mirrors Pane.prototype.addUserMessage in the
// interactive UI so live-send and history-replay both render the
// same chip strip the composer staged on submit. Attachments is a
// list of {kind, filename}; falsy/empty falls through to plain text.
// the text. Mirrors Pane.addUserMessage in the interactive UI so
// live-send and history-replay both render the same chip strip the
// composer staged on submit. Attachments is a list of
// {kind, filename}; falsy/empty falls through to plain text.
function appendUserMessageWithAttachments(text, attachments, opts) {
const el = appendText("user", text, opts);
if (!Array.isArray(attachments) || attachments.length === 0) return el;
@@ -436,7 +436,7 @@
// Metacognitive reminder bubble (user-channel correction / denial /
// resume / start / completion AND tool-channel tool_error / repeat).
// Mirrors Pane.prototype.addUserReminder / addToolReminder in the
// Mirrors Pane.addUserReminder / addToolReminder in the
// interactive UI — yellow themed bubble slotted directly below the
// message it advises. ``watch_triggered`` reminders branch off into
// the structured ``.msg.watch-result`` card. ``anchor`` is the DOM
@@ -563,7 +563,7 @@
} else if (argsRaw) {
// Malformed JSON or non-object args — show the raw payload
// truncated. Matches the interactive replay's substring(0, 100)
// fallback at ui/static/app.js Pane.prototype.replayHistory.
// fallback at ui/static/app.js Pane.replayHistory.
header = name;
preview = argsRaw.length > 200 ? argsRaw.slice(0, 200) + "…" : argsRaw;
}
@@ -10,7 +10,7 @@
<link rel="stylesheet" href="/shared/base.css">
<link rel="stylesheet" href="/shared/ui-base.css">
<link rel="stylesheet" href="/shared/chat.css">
<link rel="stylesheet" href="/shared/katex-0.16.45/katex.min.css">
<link rel="stylesheet" href="/shared/katex-0.16.47/katex.min.css">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/coordinator/coordinator.css">
<style>
@@ -634,7 +634,7 @@
<script src="/shared/composer_attachments.js"></script>
<script src="/shared/composer_queue.js"></script>
<script src="/shared/status_bar.js"></script>
<script src="/shared/katex-0.16.45/katex.min.js"></script>
<script src="/shared/katex-0.16.47/katex.min.js"></script>
<script src="/shared/hljs-11.11.1/highlight.min.js"></script>
<script src="/shared/renderer.js"></script>
<script src="/static/coordinator/coordinator.js"></script>
File diff suppressed because it is too large Load Diff
+22
View File
@@ -3602,6 +3602,28 @@ textarea.skill-content-area {
opacity: 0.4;
}
/* Phase 9 last-refresh pill in the MCP status cell. Compact age +
outcome indicator inline with the existing status text. Uses the
semantic theme tokens (--bg-highlight, --fg-dim, --warn) defined in
shared_static/base.css so the pill follows dark/light theme swaps. */
.mcp-refresh-pill {
display: inline-block;
margin-left: 6px;
padding: 0 4px;
border-radius: var(--radius-sm);
font-size: 0.8em;
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
.mcp-refresh-pill-ok {
background: var(--bg-highlight);
color: var(--fg-dim);
}
.mcp-refresh-pill-err {
background: color-mix(in srgb, var(--warn) 15%, transparent);
color: var(--warn);
}
.mcp-detail-modal::before {
background: linear-gradient(
90deg,
+15 -1
View File
@@ -513,7 +513,21 @@ def is_public_path(path: str) -> bool:
normalized = _strip_version_prefix(path)
if normalized in PUBLIC_PATHS:
return True
return any(normalized.startswith(prefix) for prefix in PUBLIC_PREFIXES)
if any(normalized.startswith(prefix) for prefix in PUBLIC_PREFIXES):
return True
# Console proxy: a public proxied path is still public. Without this
# the login/status/setup endpoints are unreachable from inside a
# ``/node/{id}/...`` proxied page once the cookie expires — the
# AuthMiddleware 401s the login POST before any handler runs and the
# user is locked out of the proxied UI.
if normalized.startswith("/node/"):
proxied = _extract_proxied_path(normalized)
if proxied is not None:
if proxied in PUBLIC_PATHS:
return True
if any(proxied.startswith(prefix) for prefix in PUBLIC_PREFIXES):
return True
return False
def required_scope(method: str, path: str) -> str:
+121
View File
@@ -0,0 +1,121 @@
"""Per-workstream wakeup primitive for in-process child state-change subscribers.
Retires the polling pattern in ``CoordinatorClient.wait_for_workstream``,
where the coord LLM's wait tool issued a storage snapshot every 0.5 s
regardless of whether anything had changed. The dispatch path
(:meth:`turnstone.console.coordinator_adapter.CoordinatorAdapter._dispatch_child_event`)
now calls :meth:`ChildEventBus.notify` after each translated child event;
waiters block on a per-call :class:`threading.Event` returned by
:meth:`register_waiter` and re-read storage only when an event fires or
the heartbeat cap expires.
Bus is in-process only. Cross-process / cross-node child events are
already merged into ``_dispatch_child_event`` via the cluster collector's
SSE multiplex before the bus sees them there is no locality branching
in the bus itself.
Design constraints:
- Waiter primitive is :class:`threading.Event` because the wait tool runs
on the coordinator's sync worker thread, not an asyncio loop.
- Concurrent ``register`` / ``unregister`` / ``notify`` is safe a
single ``threading.Lock`` guards the dict. ``Event.set`` itself is
thread-safe and is called outside the lock so a slow waker can't block
registration.
- ``notify`` with no subscribers is a no-op (the steady state most
state-change events fire while no wait tool is active).
- A waiter watching multiple ws_ids fires once on any of them; the
caller's ``_snapshot_all`` re-read resolves which one changed.
- Empty per-ws_id buckets are popped on unregister so long-lived buses
don't accumulate dead keys after wait churn.
"""
from __future__ import annotations
import threading
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable
class ChildEventBus:
"""Fan ``notify(child_ws_id)`` to every :class:`threading.Event`
registered against that ws_id.
Use :meth:`register_waiter` once per wait call to obtain an Event,
then call :meth:`unregister_waiter` in a ``finally`` so a crash mid-
wait doesn't leak the registration. The dispatch side calls
:meth:`notify` on every translated child state-change event; an
empty bucket is a cheap dict lookup + immediate return.
"""
def __init__(self) -> None:
self._waiters: dict[str, set[threading.Event]] = {}
self._lock = threading.Lock()
def register_waiter(self, child_ws_ids: Iterable[str]) -> threading.Event:
"""Return a fresh Event registered against every listed ws_id.
A wait on ``[A, B, C]`` returns a single Event that fires when
*any* of A/B/C changes. The caller's snapshot re-read resolves
which one. Empty / falsy ids are silently skipped callers that
clean their input upstream (e.g. ``wait_for_workstream``'s
dedup + cap) don't need to filter again here.
"""
event = threading.Event()
with self._lock:
for wid in child_ws_ids:
if not wid:
continue
self._waiters.setdefault(wid, set()).add(event)
return event
def unregister_waiter(
self,
child_ws_ids: Iterable[str],
event: threading.Event,
) -> None:
"""Remove ``event`` from each listed ws_id's waiter set.
Idempotent already-removed Events silently no-op. Pops empty
sets so a long-lived bus doesn't accumulate dead keys after
many waits have come and gone. Must be called from the same
``finally`` that paired with :meth:`register_waiter` so a
crash mid-wait doesn't leak the registration past one wait's
lifetime.
"""
with self._lock:
for wid in child_ws_ids:
if not wid:
continue
bucket = self._waiters.get(wid)
if bucket is None:
continue
bucket.discard(event)
if not bucket:
self._waiters.pop(wid, None)
def notify(self, child_ws_id: str) -> None:
"""Wake every Event registered for ``child_ws_id``.
Called from the coord dispatch sink after each translated child
event. Snapshot the bucket under the lock, then call
``Event.set`` outside the lock so a slow waker doesn't block
``register`` / ``unregister`` / further ``notify``. ``Event.set``
is thread-safe and idempotent re-firing a still-set Event is
a no-op.
Empty / falsy ws_ids are silently dropped; the same hot path
runs for every dispatched event regardless of whether anyone's
waiting, so the empty-bucket case must stay cheap.
"""
if not child_ws_id:
return
with self._lock:
bucket = self._waiters.get(child_ws_id)
if not bucket:
return
events = list(bucket)
for event in events:
event.set()
+23
View File
@@ -54,6 +54,27 @@ def set_config_path(path: str) -> None:
_cache = None # invalidate cache so next load_config() re-reads
def _warn_if_world_readable(cfg_path: Path) -> None:
"""Warn once if config.toml is group- or world-readable.
DB passwords, OIDC client secrets, and TLS key paths live in this
file operators usually want it at 0600. POSIX-only; no-ops where
``stat()`` modes are meaningless (Windows).
"""
try:
mode = cfg_path.stat().st_mode & 0o777
except OSError:
return
if mode & 0o077:
log.warning(
"%s is mode %04o (group/world-readable); secrets live here — "
"run `chmod 0600 %s` to restrict access",
cfg_path,
mode,
cfg_path,
)
def load_config(section: str | None = None) -> dict[str, Any]:
"""Load config.toml and return the full dict or a specific section.
@@ -66,6 +87,7 @@ def load_config(section: str | None = None) -> dict[str, Any]:
cfg_path = _resolve_config_path()
if cfg_path.is_file():
try:
_warn_if_world_readable(cfg_path)
_cache = tomllib.loads(cfg_path.read_text(encoding="utf-8"))
except Exception as exc:
log.warning("Failed to parse %s: %s", cfg_path, exc)
@@ -143,6 +165,7 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
"sslrootcert": "db_sslrootcert",
"sslcert": "db_sslcert",
"sslkey": "db_sslkey",
"listen_url": "db_listen_url",
},
"judge": {
"enabled": "judge_enabled",
+45
View File
@@ -423,6 +423,51 @@ def extract_reasoning_for_history(
msg["reasoning"] = text
def attach_vllm_chat_reasoning_field(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Project persisted reasoning onto outgoing assistant messages as a
non-standard ``reasoning`` field consumed by vLLM's chat template.
vLLM's ``ChatMessage`` (``vllm/entrypoints/openai/chat_completion/
protocol.py``) accepts a non-standard ``reasoning`` input field that
propagates into the template render context as both ``reasoning``
and ``reasoning_content``. Templates from reasoning-aware families
(Qwen3, DeepSeek-R1) inline that text on the next turn; templates
that don't read the field silently drop it. Either way the field
name doesn't conflict with the OpenAI spec — ``sanitize_messages``
preserves it because it isn't ``_``-prefixed, and the OpenAI Python
SDK passes unknown message-level fields through to the wire
(TypedDict input shape, no runtime validation).
Pure transform: returns a new list with new dict copies for the
assistant messages that get a ``reasoning`` field attached. Other
messages and assistant messages without reasoning text pass through
by reference. The original messages are never mutated.
All three gates (provider isinstance, ``server_type == "vllm"``,
operator flag ``replay_reasoning_to_model``) MUST be checked by the
caller this helper assumes the decision has already been made.
See ``ChatSession._maybe_attach_vllm_chat_reasoning`` for the
integration point.
"""
out: list[dict[str, Any]] = []
for msg in messages:
if msg.get("role") != "assistant":
out.append(msg)
continue
provider_content = msg.get("_provider_content")
if not provider_content:
out.append(msg)
continue
text = extract_reasoning_text_from_provider_content(provider_content)
if not text:
out.append(msg)
continue
out.append({**msg, "reasoning": text})
return out
def decorate_history_messages(
messages: list[dict[str, Any]],
verdicts_by_call_id: dict[str, dict[str, Any]],
+206 -2
View File
@@ -27,6 +27,7 @@ import urllib.parse
import uuid
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
@@ -45,6 +46,7 @@ from turnstone.core.config import load_config
from turnstone.core.log import get_logger
from turnstone.core.mcp_http_parsers import (
MAX_INSUFFICIENT_SCOPE_REPORTED,
is_valid_scope_token,
parse_www_authenticate_error,
parse_www_authenticate_scope,
)
@@ -510,6 +512,20 @@ class MCPClientManager:
# Notification debounce (per-server)
self._last_notification_refresh: dict[str, float] = {}
# Last refresh outcome (Phase 9 — admin status indicator). Per-
# server tuple of ``(unix_ts, outcome)`` where outcome is one of
# ``ok`` or ``error:<ExceptionClassName>``. Populated by
# ``_refresh_server`` on every call (success and failure paths),
# which means manual operator-driven refresh (``refresh_sync``)
# AND the ``_cb_auto_reconnect`` follow-up that schedules
# ``_refresh_server`` directly both populate the field — adding
# a future schedule site only needs to call ``_refresh_server``
# to participate. Read by the admin status endpoint to render
# the per-server "last refresh" pill. No initial entry is
# created at server-register time — absence surfaces as ``null``
# in the admin JSON, which the UI renders as "never".
self._last_refresh: dict[str, tuple[float, str]] = {}
# Per-(user, server) state for auth_type=oauth_user. Loop-bound:
# mutated only on the mcp-loop. Sync threads interact via
# ``asyncio.run_coroutine_threadsafe``.
@@ -2131,14 +2147,51 @@ class MCPClientManager:
Returns ``(added_tools, removed_tools)`` names (tool diff only,
for backward compatibility with ``/mcp refresh`` output).
Writes the ``_last_refresh`` entry on every call so the Phase 9
admin status pill reflects every refresh path manual
operator-driven ``refresh_sync`` AND the ``_cb_auto_reconnect``
follow-up that schedules ``_refresh_server`` directly.
Centralising the write here means future schedule sites
automatically populate the field.
Uses ``asyncio.gather(return_exceptions=True)`` so that a failure
in one of the three concurrent sub-refreshes does NOT orphan the
others mid-mutation: every sibling reaches completion (success
or per-task failure) before the outcome is computed. The
``_last_refresh`` write is ``"ok"`` iff all three succeeded; on
any failure the outcome is ``f"error:{type(first_exc).__name__}"``
and the first exception is re-raised so the outer caller's error
path (``_refresh_all``'s except, or the manual-refresh sync
wrapper) sees the same shape it did before this rework.
Partial-success mutations of ``state.tools`` / ``state.resources``
/ ``state.prompts`` are bounded to whichever sub-refresh
succeeded the documented trade-off vs leaving orphan tasks
running after the error is observed.
"""
tool_diff, _, _ = await asyncio.gather(
results = await asyncio.gather(
self._refresh_server_tools(name),
self._refresh_server_resources(name),
self._refresh_server_prompts(name),
return_exceptions=True,
)
first_exc: BaseException | None = next(
(r for r in results if isinstance(r, BaseException)), None
)
if first_exc is not None:
self._last_refresh[name] = (
time.time(),
f"error:{type(first_exc).__name__}",
)
raise first_exc
tool_diff = results[0]
# ``return_exceptions=True`` widens the static type; on the all-
# success path each entry is the awaited result. We narrow the
# tool-diff entry to the documented ``(added, removed)`` shape.
assert isinstance(tool_diff, tuple)
added, removed = tool_diff
self._last_error.pop(name, None)
self._last_refresh[name] = (time.time(), "ok")
return added, removed
async def _refresh_all(
@@ -2167,6 +2220,7 @@ class MCPClientManager:
[t["function"]["name"] for t in post.tools] if post is not None else []
)
results[name] = (new_names, [])
self._last_refresh[name] = (time.time(), "ok")
continue
added, removed = await self._refresh_server(name)
self._cb_record_success(name)
@@ -2175,6 +2229,20 @@ class MCPClientManager:
log.warning("Refresh failed for MCP server '%s'", name, exc_info=True)
self._set_error(name, f"Refresh failed: {exc}")
results[name] = ([], [])
# Overwrite unconditionally with the freshest observed
# outcome. Two cases produce the write:
# (1) Reconnect branch: ``_connect_one`` raised before
# ``_refresh_server`` could write — no prior entry
# from this iteration exists yet, so the write is
# the only fresh signal.
# (2) ``_refresh_server`` branch: it already wrote a
# fresh ``error:<ClassName>`` before re-raising, so
# the outer overwrite is a no-op for the value.
# Using ``setdefault`` here would preserve a stale prior
# ``"ok"`` from the previous successful refresh when the
# current attempt fails — the admin pill would show
# "ok" for a broken server.
self._last_refresh[name] = (time.time(), f"error:{type(exc).__name__}")
# Final sync to clean up templates from servers that are no longer connected
try:
@@ -2952,6 +3020,7 @@ class MCPClientManager:
transport = cfg.get("type", "stdio")
cb_deadline = self._circuit_open_until.get(name)
cb_open = cb_deadline is not None and time.monotonic() < cb_deadline
last_refresh = self._last_refresh.get(name)
# Inline predicate (instead of reusing ``connected``) so mypy narrows
# ``state`` for the attribute reads — a separate boolean wouldn't.
return {
@@ -2967,6 +3036,10 @@ class MCPClientManager:
"url": cfg.get("url", "") if transport != "stdio" else "",
"circuit_open": cb_open,
"consecutive_failures": self._consecutive_failures.get(name, 0),
# Phase 9 admin status: last manual / auto-reconnect refresh.
# ``null`` when no refresh has occurred since process start.
"last_refresh_at": last_refresh[0] if last_refresh is not None else None,
"last_refresh_outcome": last_refresh[1] if last_refresh is not None else None,
}
def get_all_server_status(self) -> dict[str, dict[str, Any]]:
@@ -3303,6 +3376,7 @@ class MCPClientManager:
*,
user_id: str | None = None,
timeout: int = 120,
is_interactive_for_consent: bool = True,
) -> str:
"""Execute an MCP tool call synchronously (blocks the calling thread).
@@ -3348,6 +3422,7 @@ class MCPClientManager:
arguments=arguments,
server_row=pool_target[2],
timeout=timeout,
is_interactive_for_consent=is_interactive_for_consent,
)
if mapping is None or server_name is None or original_name is None:
@@ -3500,6 +3575,54 @@ class MCPClientManager:
return None
return server_name, original, row
def _record_pending_consent_best_effort(
self,
*,
user_id: str,
server_name: str,
result: str,
) -> None:
"""Persist a deferred-consent row for non-interactive callers.
Called from the three sync dispatchers when the dispatch returns
a structured-error envelope AND the caller is not interactive
(CHAT / SCHEDULED). Filters out structured-error codes that
aren't user-consent-shaped (key-unknown, url-insecure,
*_forbidden) those are operator-actionable and outside the
scope of the dashboard pending-consent badge.
Best-effort. A storage exception is logged but never raised:
the structured-error envelope must reach the agent unchanged
regardless of whether the pending-consent row was persisted, so
a transient DB failure doesn't change the agent-observable
contract.
"""
if self._storage is None:
return
parsed = _parse_pending_consent_envelope(result)
if parsed is None:
return
code, scopes = parsed
scopes_str = " ".join(scopes) if scopes else None
try:
self._storage.upsert_mcp_pending_consent(
user_id=user_id,
server_name=server_name,
error_code=code,
scopes_required=scopes_str,
last_ws_id=None,
last_tool_call_id=None,
now_iso=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
)
except Exception:
log.warning(
"mcp_pool.pending_consent_persist_failed user=%s server=%s code=%s",
user_id,
server_name,
code,
exc_info=True,
)
def _dispatch_pool_sync(
self,
*,
@@ -3509,6 +3632,7 @@ class MCPClientManager:
arguments: dict[str, Any],
server_row: dict[str, Any],
timeout: int,
is_interactive_for_consent: bool = True,
) -> str:
"""Synchronous wrapper for pool dispatch.
@@ -3581,6 +3705,10 @@ class MCPClientManager:
server_row=server_row,
)
if _is_structured_error(result):
if not is_interactive_for_consent:
self._record_pending_consent_best_effort(
user_id=user_id, server_name=server_name, result=result
)
raise RuntimeError(result)
return result
@@ -3636,6 +3764,7 @@ class MCPClientManager:
uri: str,
server_row: dict[str, Any],
timeout: int,
is_interactive_for_consent: bool = True,
) -> str:
"""Synchronous wrapper for pool resource read.
@@ -3679,6 +3808,10 @@ class MCPClientManager:
server_row=server_row,
)
if _is_structured_error(result):
if not is_interactive_for_consent:
self._record_pending_consent_best_effort(
user_id=user_id, server_name=server_name, result=result
)
raise RuntimeError(result)
return result
@@ -3722,6 +3855,7 @@ class MCPClientManager:
arguments: dict[str, str] | None,
server_row: dict[str, Any],
timeout: int,
is_interactive_for_consent: bool = True,
) -> list[dict[str, Any]]:
"""Synchronous wrapper for pool prompt invocation.
@@ -3765,6 +3899,10 @@ class MCPClientManager:
# Structured-error path — surface as RuntimeError so the
# agent-loop renders the JSON via its except-Exception
# handler.
if not is_interactive_for_consent:
self._record_pending_consent_best_effort(
user_id=user_id, server_name=server_name, result=result
)
raise RuntimeError(result)
return result
@@ -4604,7 +4742,12 @@ class MCPClientManager:
return best
def read_resource_sync(
self, uri: str, *, user_id: str | None = None, timeout: int = 120
self,
uri: str,
*,
user_id: str | None = None,
timeout: int = 120,
is_interactive_for_consent: bool = True,
) -> str:
"""Read a resource by URI synchronously (blocks the calling thread).
@@ -4627,6 +4770,7 @@ class MCPClientManager:
uri=pool_target[1],
server_row=pool_target[2],
timeout=timeout,
is_interactive_for_consent=is_interactive_for_consent,
)
mapping = self._resource_map.get(uri)
@@ -4689,6 +4833,7 @@ class MCPClientManager:
*,
user_id: str | None = None,
timeout: int = 30,
is_interactive_for_consent: bool = True,
) -> list[dict[str, Any]]:
"""Invoke an MCP prompt synchronously and return expanded messages.
@@ -4727,6 +4872,7 @@ class MCPClientManager:
arguments=arguments,
server_row=pool_target[2],
timeout=timeout,
is_interactive_for_consent=is_interactive_for_consent,
)
if static_mapping is None:
@@ -4912,6 +5058,64 @@ def _structured_error(
return json.dumps({"error": err})
# Structured-error codes that represent a deferred-consent need. When
# encountered on a non-interactive call (chat / scheduled), the sync
# dispatcher persists a row to ``mcp_pending_consent`` so the dashboard
# badge can surface the deferred work later. Operator-actionable codes
# (key-unknown, url-insecure, *_forbidden) are intentionally excluded —
# the user cannot resolve them by completing a consent flow.
_PENDING_CONSENT_PERSIST_CODES: frozenset[str] = frozenset(
{"mcp_consent_required", "mcp_insufficient_scope"}
)
def _parse_pending_consent_envelope(
result: str,
) -> tuple[str, list[str] | None] | None:
"""Extract ``(error_code, scopes_required)`` from a structured-error JSON.
Returns ``None`` when the envelope's ``code`` is not in
:data:`_PENDING_CONSENT_PERSIST_CODES`. Callers should already have
gated on :func:`_is_structured_error`; this helper deliberately
re-parses (cheap on the failure path) rather than threading the
decoded dict through the sync-dispatcher hot path.
Defends against non-dict JSON values (``null``, strings, numbers)
via the same ``isinstance(decoded, dict)`` guard
:func:`_is_structured_error` uses, so a misuse from a future caller
that bypasses the structured-error contract surfaces as a clean
``None`` rather than an ``AttributeError`` propagating out of the
sync dispatcher's hot path.
"""
try:
decoded = json.loads(result)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(decoded, dict):
return None
err = decoded.get("error")
if not isinstance(err, dict):
return None
code = err.get("code", "")
if code not in _PENDING_CONSENT_PERSIST_CODES:
return None
scopes = err.get("scopes_required")
if isinstance(scopes, list):
# Defense-in-depth scope filter — production paths construct
# this list via ``parse_www_authenticate_scope`` which already
# validates and caps, but the helper is reusable; re-applying
# the predicate here forecloses any future caller that bypasses
# the upstream filter from landing attacker-controlled bytes in
# ``mcp_pending_consent.scopes_required``. Type-filter BEFORE
# ``is_valid_scope_token`` so non-string entries (``None``,
# ints) don't slip through as their ``str()`` repr (e.g.
# ``None`` → ``"None"`` passes the ASCII grammar). Cap mirrors
# ``MAX_INSUFFICIENT_SCOPE_REPORTED`` semantics.
cleaned = [s for s in scopes if isinstance(s, str) and is_valid_scope_token(s)]
return code, cleaned[:MAX_INSUFFICIENT_SCOPE_REPORTED]
return code, None
def _is_structured_error(result: str) -> bool:
"""Return True if *result* parses as a :func:`_structured_error` envelope.
+166
View File
@@ -2350,6 +2350,22 @@ async def _handle_mcp_oauth_callback_inner(request: Request) -> Response:
},
)
# Phase 9 — clear any deferred-consent records for this (user,
# server) now that consent has completed. Best-effort: a storage
# failure here doesn't change the user-observable callback success;
# the worst case is a stale badge that the user can dismiss
# manually. ``delete_mcp_pending_consent`` returns False on
# no-such-row (the common case for interactive consent flows that
# never deferred), which is fine.
try:
await asyncio.to_thread(storage.delete_mcp_pending_consent, user_id, server_name)
except Exception:
log.debug(
"mcp_server.oauth.pending_consent_clear_failed",
server_name=server_name,
exc_info=True,
)
return RedirectResponse(pending["return_url"] or "/", status_code=302)
@@ -2618,6 +2634,153 @@ async def _handle_mcp_oauth_revoke_connection_inner(request: Request) -> Respons
return Response(status_code=204)
# ---------------------------------------------------------------------------
# Pending-consent endpoints (Phase 9)
# ---------------------------------------------------------------------------
async def handle_mcp_oauth_list_pending(request: Request) -> Response:
"""``GET /v1/api/mcp/oauth/pending``.
Returns the authenticated user's deferred-consent records — populated
by the pool dispatchers when a non-interactive run (scheduled /
channel) hits ``mcp_consent_required`` or ``mcp_insufficient_scope``.
Used by the dashboard badge to surface deferred consent needs on
next login.
Install-level gate: when no ``mcp_servers`` row has
``auth_type='oauth_user'``, the entire feature is dark we
short-circuit to ``{pending: 0, servers: []}`` without querying the
pending table at all. This keeps local-auth installs on a
zero-new-storage-query path.
"""
return _apply_security_headers(await _handle_mcp_oauth_list_pending_inner(request))
_INSTALL_GATE_CACHE_TTL_S = 60.0
async def _install_gate_passes(app_state: Any, storage: Any) -> bool:
"""Cached install-level gate for OAuth-MCP features.
Returns True iff at least one ``mcp_servers`` row has
``auth_type='oauth_user'``. Result is cached on ``app_state`` for
:data:`_INSTALL_GATE_CACHE_TTL_S` seconds admin-rare transitions
don't justify a per-request DB round-trip on every dashboard load.
Reset semantics: cache is invalidated by time only. Operators who
just enabled an ``oauth_user`` row see the gate flip within the TTL
window. False positives (cache says True but the row was just
deleted) are bounded by the same window the downstream list
query already filters by user, so the cost is at most one cheap
user-scoped read.
"""
now = time.monotonic()
cached = getattr(app_state, "_mcp_install_gate_cache", None)
if cached is not None:
cached_value, cached_at = cached
if (now - cached_at) < _INSTALL_GATE_CACHE_TTL_S:
return bool(cached_value)
value = bool(await asyncio.to_thread(storage.any_oauth_user_mcp_servers))
app_state._mcp_install_gate_cache = (value, now)
return value
async def _handle_mcp_oauth_list_pending_inner(request: Request) -> Response:
from starlette.responses import JSONResponse
user_id = _require_user_id(request)
if user_id is None:
return JSONResponse({"error": "Authentication required"}, status_code=401)
storage = _get_storage(request.app.state)
if storage is None:
return JSONResponse({"pending": 0, "servers": []})
if not await _install_gate_passes(request.app.state, storage):
return JSONResponse({"pending": 0, "servers": []})
rows = await asyncio.to_thread(storage.list_mcp_pending_consent_by_user, user_id)
return JSONResponse({"pending": len(rows), "servers": list(rows)})
async def handle_mcp_oauth_clear_pending(request: Request) -> Response:
"""``DELETE /v1/api/mcp/oauth/pending/{server_name}``.
Manual user-initiated dismissal of a single deferred-consent record.
Called from the dashboard settings modal when the user opts to clear
the entry without completing consent (e.g., the underlying
auth_type was changed and the deferred record is now stale).
Returns 204 in both the existed-and-deleted and never-existed cases
to keep cross-tenant existence non-observable.
"""
return _apply_security_headers(await _handle_mcp_oauth_clear_pending_inner(request))
async def _handle_mcp_oauth_clear_pending_inner(request: Request) -> Response:
from starlette.responses import JSONResponse, Response
user_id = _require_user_id(request)
if user_id is None:
return JSONResponse({"error": "Authentication required"}, status_code=401)
server_name = request.path_params.get("server_name", "").strip()
if not server_name:
return JSONResponse({"error": "Missing server_name"}, status_code=400)
storage = _get_storage(request.app.state)
if storage is None:
return JSONResponse({"error": "Storage unavailable"}, status_code=503)
cleared = bool(
await asyncio.to_thread(storage.delete_mcp_pending_consent, user_id, server_name)
)
# Audit even on no-op deletes (returns 204 either way for cross-tenant
# non-observability) so an attacker who tries to scrub deferred-consent
# breadcrumbs leaves an audit trail of the attempts.
await _audit_event(
request.app.state,
user_id=user_id,
action="mcp_server.oauth.pending_consent_dismissed",
server_name=server_name,
detail={"mode": "single", "cleared": 1 if cleared else 0},
)
return Response(status_code=204)
async def handle_mcp_oauth_clear_all_pending(request: Request) -> Response:
"""``DELETE /v1/api/mcp/oauth/pending``.
Bulk dismiss of every deferred-consent record for the authenticated
user. Returns the count cleared so the dashboard can update its
badge in one round-trip.
"""
return _apply_security_headers(await _handle_mcp_oauth_clear_all_pending_inner(request))
async def _handle_mcp_oauth_clear_all_pending_inner(request: Request) -> Response:
from starlette.responses import JSONResponse
user_id = _require_user_id(request)
if user_id is None:
return JSONResponse({"error": "Authentication required"}, status_code=401)
storage = _get_storage(request.app.state)
if storage is None:
return JSONResponse({"error": "Storage unavailable"}, status_code=503)
cleared = await asyncio.to_thread(storage.delete_all_mcp_pending_consent_by_user, user_id)
await _audit_event(
request.app.state,
user_id=user_id,
action="mcp_server.oauth.pending_consent_dismissed",
server_name="(bulk)",
detail={"mode": "bulk", "cleared": cleared},
)
return JSONResponse({"cleared": cleared})
# ---------------------------------------------------------------------------
# Lifespan integration
# ---------------------------------------------------------------------------
@@ -2674,7 +2837,10 @@ __all__ = [
"get_user_access_token_classified",
"handle_mcp_oauth_authorize",
"handle_mcp_oauth_callback",
"handle_mcp_oauth_clear_all_pending",
"handle_mcp_oauth_clear_pending",
"handle_mcp_oauth_list_connections",
"handle_mcp_oauth_list_pending",
"handle_mcp_oauth_revoke_connection",
"initialize_mcp_oauth_state",
"pop_pending_state",
+8 -4
View File
@@ -500,10 +500,14 @@ def load_model_registry(
server_compat=entry_server_compat,
)
# 3. Ensure a "default" entry from CLI args (only if not already defined
# by config.toml or DB — those take precedence, and only when a CLI
# model was actually provided)
if "default" not in configs and model:
# 3. Back-compat shim: synthesize a "default" alias from CLI/auto-detected
# ``--base-url`` + ``--model`` only when no DB or config.toml models exist.
# Auto-creating "default" alongside DB models leaks a non-routing alias
# into the public list — the LLM picks it in plan_agent / task_agent
# ``model=`` and silently bypasses the operator's per-role
# plan_alias / task_alias overrides (the "default" alias points at
# whatever LLM_BASE_URL was at boot, not at the configured default).
if not configs and model:
configs["default"] = ModelConfig(
alias="default",
base_url=base_url,
+35 -10
View File
@@ -19,11 +19,11 @@ Channels:
Drain preserves FIFO order; non-matching entries stay queued. Each
entry can carry an optional ``valid_until`` predicate that drain
evaluates outside the queue lock; entries whose predicate returns
``False`` (or raises) are silently dropped without delivery used by
producers whose payload becomes stale if the underlying state changes
between enqueue and drain (e.g. ``idle_children`` re-checks the active
child set, dropping the nudge if every child finished while the queue
sat). Operations are atomic under an internal :class:`threading.Lock`.
``False`` are dropped (logged at ``info`` normal lifecycle outcome,
e.g. ``idle_children`` after every child closed) and entries whose
predicate raises are dropped (logged at ``warning`` with ``exc_info``
a misbehaving predicate). Operations are atomic under an internal
:class:`threading.Lock`.
"""
from __future__ import annotations
@@ -32,9 +32,13 @@ import threading
from collections import deque
from typing import TYPE_CHECKING, Any, Literal, NamedTuple
from turnstone.core.log import get_logger
if TYPE_CHECKING:
from collections.abc import Callable
log = get_logger(__name__)
Channel = Literal["user", "tool", "any"]
_VALID_CHANNELS: frozenset[str] = frozenset({"user", "tool", "any"})
@@ -139,7 +143,12 @@ class NudgeQueue:
self._items = kept
# Predicates evaluate outside the lock — they may do storage
# I/O or other work that shouldn't block other producers /
# the drain consumer's other queues.
# the drain consumer's other queues. Drop-level distinction:
# a ``False`` return is a normal lifecycle outcome (the
# producer's snapshot is stale — e.g. ``idle_children`` after
# every child closed) and logs at ``info``; a raised exception
# is a wiring bug (predicate is misbehaving) and stays at
# ``warning`` with ``exc_info`` so the traceback surfaces.
out: list[tuple[str, str, dict[str, Any] | None]] = []
for entry in candidates:
if entry.valid_until is None:
@@ -148,11 +157,27 @@ class NudgeQueue:
try:
if entry.valid_until():
out.append((entry.nudge_type, entry.text, entry.metadata))
continue
log.info(
"nudge_queue.predicate_dropped",
extra={
"nudge_type": entry.nudge_type,
"channel": entry.channel,
"reason": "predicate_false",
"text_len": len(entry.text),
},
)
except Exception:
# Predicate raising is treated as "no longer valid" —
# drop silently rather than letting one bad predicate
# poison the whole drain batch.
pass
log.warning(
"nudge_queue.predicate_dropped",
extra={
"nudge_type": entry.nudge_type,
"channel": entry.channel,
"reason": "predicate_raised",
"text_len": len(entry.text),
},
exc_info=True,
)
return out
def __len__(self) -> int:
+432 -83
View File
@@ -45,12 +45,15 @@ from turnstone.core.attachments import (
)
from turnstone.core.config import get_tavily_key
from turnstone.core.edit import find_occurrences, pick_nearest
from turnstone.core.history_decoration import extract_advisories_from_tool_envelope
from turnstone.core.history_decoration import (
attach_vllm_chat_reasoning_field,
extract_advisories_from_tool_envelope,
)
from turnstone.core.log import get_logger
from turnstone.core.memory import (
count_structured_memories,
delete_messages_after,
delete_structured_memory,
delete_structured_memory_by_id,
delete_workstream,
get_skill_by_name,
get_structured_memory_by_name,
@@ -112,7 +115,12 @@ from turnstone.core.tools import (
from turnstone.core.watch import WATCH_REMINDER_OPTIONAL_KEYS
from turnstone.core.web import check_ssrf, strip_html
from turnstone.core.workstream import WorkstreamKind
from turnstone.prompts import ClientType, SessionContext, compose_system_message
from turnstone.prompts import (
INTERACTIVE_CONSENT_CLIENT_TYPES,
ClientType,
SessionContext,
compose_system_message,
)
from turnstone.ui.colors import DIM, GRAY, GREEN, RED, RESET, YELLOW, bold, cyan, dim
log = get_logger(__name__)
@@ -851,6 +859,15 @@ class ChatSession:
self._mcp_user_id: str | None = user_id or None
self._username = username
self._client_type = client_type
# Whether the user is online to complete an in-flight OAuth
# consent redirect. WEB and CLI users are; CHAT (Discord /
# Slack) and SCHEDULED (autonomous runs) are not — their
# consent-required errors must be persisted to
# ``mcp_pending_consent`` by the pool dispatchers for later
# surfacing on the dashboard badge, rather than relying on the
# in-flight SSE rendering path that Phase 8 ships for
# interactive surfaces.
self._is_interactive_for_consent: bool = client_type in INTERACTIVE_CONSENT_CLIENT_TYPES
self._config_store = config_store
# Initialize rule registry for configurable judge rules
self._rule_registry = None
@@ -1155,20 +1172,30 @@ class ChatSession:
Used by :meth:`_maybe_synth_reasoning_block` to tag synthetic
path-3 reasoning blocks with their origin server (vllm,
llama.cpp, sglang, etc.) informational today, useful for
future per-server replay paths. Returns ``""`` on any lookup
miss; the synthetic block then omits the ``source`` field.
llama.cpp, sglang, etc.) informational metadata for UI
rehydration. Returns ``""`` on any lookup miss.
Phase 5 (:meth:`_maybe_attach_vllm_chat_reasoning`) does NOT
call this resolver it reads ``cfg.server_compat["server_type"]``
directly off the single ``cfg`` it already fetched for the
``replay_reasoning_to_model`` flag check, to avoid a second
``registry.get_config`` round-trip. Both readers MUST stay
aligned on the same field path; if you change one, change the
other.
Reads ``cfg.server_compat`` (the dedicated dataclass field set
by the model_registry loader) NOT ``cfg.capabilities``. Both
loader paths (DB at ``model_registry.py:401`` and config.toml at
``model_registry.py:485``) ``caps.pop("server_compat", {})`` and
hoist the dict to the top-level field, so the capabilities dict
never carries server_compat in production.
"""
target_alias = alias or self._model_alias or ""
if not self._registry or not target_alias:
return ""
try:
cfg: ModelConfig = self._registry.get_config(target_alias)
sc = (
cfg.capabilities.get("server_compat")
if isinstance(cfg.capabilities, dict)
else None
)
sc = cfg.server_compat if isinstance(cfg.server_compat, dict) else None
if isinstance(sc, dict):
return str(sc.get("server_type") or "")
except Exception:
@@ -1225,11 +1252,14 @@ class ChatSession:
The optional ``source`` field tags the block with the
originating server (``vllm``, ``llamacpp``, ``sglang``, etc.)
when ``ModelConfig.capabilities["server_compat"]["server_type"]``
is populated. Reserved for future per-server replay paths
(e.g. an operator-flagged path that re-injects synthetic
reasoning back into a vllm round-trip) not consumed today;
the field is informational metadata, not dead code.
resolved via :meth:`_resolve_server_type`, which reads
``cfg.server_compat["server_type"]`` (the dedicated dataclass
field hoisted by the model_registry loader, NOT
``cfg.capabilities``). The synthetic block's ``source`` field
itself is informational metadata; Phase 5's vLLM replay path
(:meth:`_maybe_attach_vllm_chat_reasoning`) reads
``cfg.server_compat`` directly rather than the synthetic
block's tag.
"""
text = "".join(reasoning_parts)
if not text.strip():
@@ -1294,6 +1324,65 @@ class ChatSession:
return operator_on
return operator_on and bool(caps.supports_reasoning_replay)
def _maybe_attach_vllm_chat_reasoning(
self,
messages: list[dict[str, Any]],
provider: LLMProvider,
alias: str | None = None,
) -> list[dict[str, Any]]:
"""Conditionally attach vLLM's non-standard ``reasoning`` field to
outgoing assistant messages so a vLLM-served reasoning model can
thread CoT across turns.
Phase 5 of reasoning-persistence parallel path to Paths 1+2,
not a modification. Three gates:
1. Provider is ``OpenAIChatCompletionsProvider`` (Chat Completions
surface, not Responses or Anthropic those have their own
replay paths with loud-failure-protected dual-gates).
2. ``server_compat.server_type == "vllm"`` bounds blast radius
to vLLM; canonical OpenAI / llama.cpp / sglang never see the
non-standard field.
3. Operator-set ``ModelConfig.replay_reasoning_to_model`` same
per-model toggle PR #498 added; defaults False.
The static ``supports_reasoning_replay`` capability gate that
guards Paths 1+2 is intentionally NOT used here. vLLM's chat
template silently drops ``reasoning`` if the loaded template
doesn't read ``reasoning_content`` — the gate would add code-
edit friction (capability tables live in
``providers/_openai_common.py``, not the admin UI) without
preventing the silent failure that's the actual misconfiguration
risk. Paths 1+2 keep the dual-gate because their failure mode
is loud (Anthropic 400 on unsigned thinking, OpenAI Responses
400 on ResponseReasoningItemParam for non-reasoning models);
Path C's failure is silent so the gate doesn't help.
Returns *messages* unchanged when any gate fails.
"""
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
if not isinstance(provider, OpenAIChatCompletionsProvider):
return messages
target_alias = alias or self._model_alias or ""
if not self._registry or not target_alias:
return messages
try:
cfg = self._registry.get_config(target_alias)
except Exception:
return messages
# Read both gate fields off the single ``cfg`` we already
# fetched, rather than re-entering ``_resolve_server_type``
# (which would do a second ``get_config`` call). Mirrors the
# field location ``_resolve_server_type`` reads, so the two
# gates stay aligned if the loader ever changes shape.
sc = cfg.server_compat if isinstance(cfg.server_compat, dict) else None
if not isinstance(sc, dict) or sc.get("server_type") != "vllm":
return messages
if not bool(cfg.replay_reasoning_to_model):
return messages
return attach_vllm_chat_reasoning_field(messages)
def _save_config(self) -> None:
"""Persist LLM-affecting config so resumed workstreams behave identically."""
save_workstream_config(
@@ -1516,9 +1605,13 @@ class ChatSession:
"""
if self._registry is None:
return
aliases = sorted(self._registry.list_aliases())
if not aliases:
return
# Hide ``default`` from the alias list — the LLM reads the English
# word and picks it explicitly, which routes to whichever model
# carries that alias rather than the operator-configured per-role
# default (plan_alias / task_alias). Omitting ``model=`` already
# selects the per-role default; offering the literal name as an
# alternative invites the bypass.
aliases = sorted(a for a in self._registry.list_aliases() if a != "default")
aliases_str = ", ".join(f"`{a}`" for a in aliases)
new_tools: list[dict[str, Any]] = []
@@ -1532,11 +1625,22 @@ class ChatSession:
new_tool = copy.deepcopy(tool)
props = new_tool.get("function", {}).get("parameters", {}).get("properties", {})
if "model" in props:
props["model"]["description"] = (
f"Optional model alias to run this {name} on. "
f"Omit to use the operator-configured {kind}. "
f"Available aliases: {aliases_str}."
)
# Always rewrite — a reload that filters down to no
# alternatives (only ``default`` remains in the registry)
# must clear any stale alias names left over from a prior
# render, not return early and leave them in place.
if aliases:
props["model"]["description"] = (
f"Optional model alias to run this {name} on. "
f"Omit to use the operator-configured {kind}. "
f"Available aliases: {aliases_str}."
)
else:
props["model"]["description"] = (
f"Optional model alias to run this {name} on. "
"Omit to use the current session model. "
"(No alternative aliases configured in this session.)"
)
new_tools.append(new_tool)
self._tools = new_tools
@@ -1641,13 +1745,17 @@ class ChatSession:
The closure carries:
- a soft cap on per-session ``"watch_triggered"`` depth via
:data:`_WATCH_QUEUE_SOFT_CAP` + drop-oldest-on-saturation.
- a ``valid_until`` predicate that re-checks
``storage.is_watch_active(watch_id)`` at drain time so a
cancelled watch's last splat doesn't ride out a future wake.
- producer-side :func:`sanitize_payload` over the whole
formatted message so steering-vector / control-char payloads
sourced from arbitrary shell output can't tamper with the
envelope at interpolation time.
No ``valid_until`` predicate is wired: ``WatchRunner._poll_watch``
commits ``active=False`` for terminal fires right after dispatch
returns, and an ``is_watch_active`` predicate would race that
write at drain time and drop the fire the model was meant to see.
A user-cancelled watch's last splat is informative (the reminder
carries ``is_final=True``), not stale-noise to suppress.
"""
self._watch_runner = runner
nudge_queue = self._nudge_queue
@@ -1678,18 +1786,6 @@ class ChatSession:
_WATCH_QUEUE_SOFT_CAP,
)
def _still_active() -> bool:
# Re-checked at drain time outside the queue lock — if
# the watch was cancelled between fire and drain, the
# entry gets dropped silently rather than splicing a
# stale result onto the user's next turn. Single-column
# ``is_watch_active`` avoids the full-row marshal of
# ``get_watch`` on this hot path.
try:
return get_storage().is_watch_active(watch_id)
except Exception:
return False
def _maybe_sanitize(v: Any) -> Any:
return sanitize_payload(v) if isinstance(v, str) else v
@@ -1702,7 +1798,6 @@ class ChatSession:
"watch_triggered",
sanitized,
"any",
valid_until=_still_active,
metadata=metadata or None,
)
@@ -2752,6 +2847,7 @@ class ChatSession:
"""
caps = self._get_capabilities()
clamped = min(max_tokens, caps.max_output_tokens) if caps.max_output_tokens else max_tokens
messages = self._maybe_attach_vllm_chat_reasoning(messages, self._provider)
return self._provider.create_completion(
client=self.client,
model=self.model,
@@ -2948,6 +3044,7 @@ class ChatSession:
msg_count,
role_counts,
)
msgs = self._maybe_attach_vllm_chat_reasoning(msgs, prov, model_alias)
last_err: Exception | None = None
for attempt in range(self._MAX_RETRIES + 1):
self._check_cancelled()
@@ -4577,7 +4674,18 @@ class ChatSession:
elif name == "notify":
it["func_args"] = {"message": (it.get("message") or "")[:200]}
elif name == "task_agent":
it["func_args"] = {"prompt": (it.get("prompt") or "")[:200]}
# Pending items reach this point already shaped by
# ``_prepare_task``, so ``it["skill"]`` is the resolved
# skill_data dict (or ``None``), not the raw string the
# LLM passed. Mirror the ``spawn_workstream`` projection
# so heuristic ``arg_pattern`` rules can match on skill
# name and the judge / audit row sees which persona was
# selected.
skill_dict = it.get("skill") or {}
it["func_args"] = {
"prompt": (it.get("prompt") or "")[:200],
"skill": skill_dict.get("name", "") if isinstance(skill_dict, dict) else "",
}
elif name == "plan_agent":
it["func_args"] = {"goal": (it.get("prompt") or "")[:200]}
# Coordinator tool args — only the ``needs_approval=True`` set
@@ -6151,9 +6259,39 @@ class ChatSession:
alias = str(raw).strip()
if not alias:
return None, None
# ``default`` is operator-only — the alias either back-compat-shims
# a single-CLI-model registry or aliases a hand-named DB row, and
# in both cases an LLM that explicitly routes here bypasses the
# operator-configured ``plan_alias`` / ``task_alias`` per-role
# default. Symmetric with the description filter at
# ``_render_agent_tool_descriptions`` — closes the loophole where
# an LLM that learned the alias name out-of-band (training data,
# prior turn, prompt injection) can re-issue it directly.
if alias == "default":
return None, {
"call_id": call_id,
"func_name": func_name,
"header": f"\u2717 {func_name}: 'default' is not selectable",
"preview": "",
"needs_approval": False,
"error": (
"Error: 'default' is not a selectable model alias for "
f"{func_name}. Omit `model=` to use the operator-configured "
"per-role default."
),
}
if self._registry is None or not self._registry.has_alias(alias):
available = sorted(self._registry.list_aliases()) if self._registry is not None else []
available_str = ", ".join(available) if available else "(no registry configured)"
# ``default`` excluded from the retry list so an LLM probing
# with a bogus alias can't enumerate it back from the error.
if self._registry is None:
available_str = "(no registry configured)"
else:
available = sorted(a for a in self._registry.list_aliases() if a != "default")
available_str = (
", ".join(available)
if available
else "(no alternative aliases configured — omit `model=`)"
)
return None, {
"call_id": call_id,
"func_name": func_name,
@@ -6179,17 +6317,77 @@ class ChatSession:
model_override, err = self._validate_agent_model_override(call_id, "task_agent", args)
if err is not None:
return err
skill_arg = (args.get("skill") or "").strip()
skill_data: dict[str, Any] | None = None
if skill_arg:
skill_data = get_skill_by_name(skill_arg)
if skill_data is None:
return {
"call_id": call_id,
"func_name": "task_agent",
"header": f"\u2717 task_agent: unknown skill '{skill_arg}'",
"preview": "",
"needs_approval": False,
"error": (
f"Error: unknown skill '{skill_arg}'. "
"Use skill(action='search') to find available names."
),
}
# ``enabled=False`` is an admin's quarantine flag \u2014 mirror the
# gate that ``_exec_skill(action='load')`` and skill-search
# apply so task_agent can't sidestep it. Distinct from the
# not-found case so the LLM's recovery path can tell them apart.
if not skill_data.get("enabled", True):
return {
"call_id": call_id,
"func_name": "task_agent",
"header": f"\u2717 task_agent: skill '{skill_arg}' is disabled",
"preview": "",
"needs_approval": False,
"error": (
f"Error: skill '{skill_arg}' is disabled and cannot be used. "
"Use skill(action='search') to find available names."
),
}
# ``get_skill_by_name`` returns the full prompt_templates row
# (~30 columns including ``scan_report``, ``installed_by``,
# ``source_url``, etc.). Project to the minimal field set
# that ``_exec_task`` and ``_evaluate_intent`` actually read,
# so the approval item doesn't drag governance metadata
# through any future audit serializer.
skill_data = {
"name": skill_data["name"],
"content": skill_data["content"],
"risk_level": skill_data.get("risk_level", ""),
}
preview_text = prompt[:300] + ("..." if len(prompt) > 300 else "")
header = "\u2699 task_agent (autonomous agent"
if skill_data:
header += f", skill: {skill_data['name']}"
# Surface high/critical risk at approval time so the operator
# sees the same signal ``_load_skills`` emits for session-level
# skills (session.py:1336). Log mirrors that path's structured
# event for forensic continuity.
risk_tier = skill_data.get("risk_level", "")
if risk_tier in ("high", "critical"):
header += f", risk: {risk_tier}"
log.warning(
"task_agent.high_risk_skill",
skill=skill_data["name"],
risk_level=risk_tier,
)
header += ")"
return {
"call_id": call_id,
"func_name": "task_agent",
"header": "\u2699 task_agent (autonomous agent)",
"header": header,
"preview": f" {preview_text}",
"needs_approval": True,
"approval_label": "task_agent",
"execute": self._exec_task,
"prompt": prompt,
"model_override": model_override,
"skill": skill_data,
}
def _prepare_plan(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
@@ -6884,9 +7082,20 @@ class ChatSession:
msg = f"Error: {result['error']}"
self._report_tool_result(call_id, "spawn_workstream", msg, is_error=True)
return call_id, msg
# Successful spawn — surface ws_id + node_id + name + routing
# strategy so the coordinator can follow up with inspect / send
# and explain why a given node was chosen. ``status`` was
# Defensive: absence of ``error`` is the success signal, but
# a malformed upstream response could land here with no
# ``ws_id``. Without this check the LLM gets
# ``{"child_ws_id": null}`` and chases a null id through
# follow-up tools. Mirrors the matching guard in
# ``_exec_spawn_batch`` (denied row on empty ws_id).
child_ws_id = str(result.get("ws_id") or "")
if not child_ws_id:
msg = "Error: spawn returned no ws_id"
self._report_tool_result(call_id, "spawn_workstream", msg, is_error=True)
return call_id, msg
# Successful spawn — surface child_ws_id + node_id + name +
# routing strategy so the coordinator can follow up with inspect
# / send and explain why a given node was chosen. ``status`` was
# historically included but it was the routing-proxy's HTTP
# code (always 200 on this branch); the absence of an
# ``error`` field is the success signal. Dropped here to
@@ -6897,14 +7106,19 @@ class ChatSession:
# ``inspect_workstream``.
summary = json.dumps(
{
"ws_id": result.get("ws_id"),
# Key is ``child_ws_id`` (not ``ws_id``) so the coordinator
# LLM doesn't recency-bias toward feeding the spawn-return
# straight back into another ``spawn_workstream(ws_id=...)``
# call. On large fan-outs this cascaded into self-inflicted
# re-spawn loops instead of progressing to ``wait_for_workstream``.
"child_ws_id": child_ws_id,
"name": result.get("name"),
"node_id": result.get("node_id"),
"routing_strategy": result.get("routing_strategy"),
},
separators=(",", ":"),
)
self._report_tool_result(call_id, "spawn_workstream", f"spawned {result.get('ws_id', '?')}")
self._report_tool_result(call_id, "spawn_workstream", f"spawned {child_ws_id}")
return call_id, summary
# Cap per batch call. Matches the ``wait_for_workstream`` ws_ids
@@ -7035,7 +7249,9 @@ class ChatSession:
denied.append({"idx": idx, "reason": "spawn returned no ws_id"})
continue
results[str(idx)] = {
"ws_id": ws_id,
# ``child_ws_id`` (not ``ws_id``) — see the matching
# comment in ``_exec_spawn_workstream``.
"child_ws_id": ws_id,
"name": result.get("name", ""),
"node_id": result.get("node_id", ""),
# ``status`` deliberately omitted — see the matching
@@ -7113,6 +7329,8 @@ class ChatSession:
}
def _exec_inspect_workstream(self, item: dict[str, Any]) -> tuple[str, str]:
from turnstone.console.coordinator_client import _format_inspect_tiered
call_id = item["call_id"]
ws_id = item["ws_id"]
try:
@@ -7125,8 +7343,13 @@ class ChatSession:
msg = f"Error: inspect_workstream failed: {e}"
self._report_tool_result(call_id, "inspect_workstream", msg, is_error=True)
return call_id, msg
output = json.dumps(result, default=str, separators=(",", ":"))
# Summary for UI: state + message count
# Tiered output: full → compact (head/tail-snipped messages) →
# skeleton (counts + last-assistant preview). First tier that
# fits the budget wins; the LLM sees a ``_tier`` field on every
# non-error response. ``_truncate_output`` remains the safety
# net for the (rare) skeleton-exceeds-budget case — guarding
# against a single-field blowup we didn't anticipate.
output = _format_inspect_tiered(result)
desc = f"{result.get('state', '?')} ({len(result.get('messages', []))} msgs)"
self._report_tool_result(call_id, "inspect_workstream", desc)
return call_id, self._truncate_output(output)
@@ -8479,6 +8702,7 @@ class ChatSession:
args,
user_id=self._mcp_user_id,
timeout=self.tool_timeout,
is_interactive_for_consent=self._is_interactive_for_consent,
)
except TimeoutError:
output = f"MCP tool timed out after {self.tool_timeout}s"
@@ -8561,7 +8785,10 @@ class ChatSession:
# 401 / 403 / consent-required handling. Otherwise the
# static path runs byte-identical (invariant 1).
output = self._mcp_client.read_resource_sync(
uri, user_id=self._mcp_user_id, timeout=self.tool_timeout
uri,
user_id=self._mcp_user_id,
timeout=self.tool_timeout,
is_interactive_for_consent=self._is_interactive_for_consent,
)
except TimeoutError:
output = f"MCP resource read timed out after {self.tool_timeout}s"
@@ -8658,6 +8885,7 @@ class ChatSession:
arguments or None,
user_id=self._mcp_user_id,
timeout=self.tool_timeout,
is_interactive_for_consent=self._is_interactive_for_consent,
)
output = "\n\n".join(f"[{m['role']}]: {m['content']}" for m in messages)
except TimeoutError:
@@ -9224,6 +9452,13 @@ class ChatSession:
messages: list[dict[str, Any]],
_tools: list[dict[str, Any]] | None = tools,
) -> CompletionResult:
# NOTE: Phase 5 vLLM ``reasoning`` field replay is intentionally
# NOT wired here. Agent assistant messages are built from
# ``CompletionResult.content + tool_calls`` only (no
# ``_provider_content`` carried), so the helper would no-op
# every turn anyway. Plan/task agents are excluded from the
# persistence/replay contract — their conversation history
# is in-memory and rebuilt per ``_run_agent`` invocation.
last_err: Exception | None = None
for attempt in range(self._MAX_RETRIES + 1):
try:
@@ -9375,35 +9610,68 @@ class ChatSession:
self.ui.on_info(f"[{label} done] {len(content)} chars")
return content
_TASK_DEFAULT_IDENTITY = (
"# Task Agent\n\n"
"You are an autonomous task agent with full tool access. "
"You can use bash, read_file, write_file, edit_file, search, "
"math, web_fetch, and web_search."
)
# Operating guidance always applies — these are sub-agent semantics
# (one-shot, tool-use over narration, no follow-up questions) that a
# persona skill should layer on top of, not replace.
_TASK_OPERATING_GUIDANCE = (
"1. **Follow through on actions:** Do not describe changes — "
"use the tools to make them. After read_file, call edit_file "
"or write_file.\n\n"
"2. **Tool selection:**\n"
" - Use read_file before edit_file on existing files.\n"
" - Use write_file for new files (not bash).\n"
" - Use bash for shell commands (git, python, tests).\n"
" - Use search to find code across files.\n\n"
"3. **Complete the task fully.** Do not ask follow-up "
"questions — execute the work as described in the prompt."
)
def _exec_task(self, item: dict[str, Any]) -> tuple[str, str]:
"""Delegate to a general-purpose autonomous sub-agent."""
call_id, prompt = item["call_id"], item["prompt"]
task_instruction = {
"role": "system",
"content": (
"# Task Agent\n\n"
"You are an autonomous task agent with full tool access. "
"You can use bash, read_file, write_file, edit_file, search, "
"math, web_fetch, and web_search.\n\n"
"1. **Follow through on actions:** Do not describe changes — "
"use the tools to make them. After read_file, call edit_file "
"or write_file.\n\n"
"2. **Tool selection:**\n"
" - Use read_file before edit_file on existing files.\n"
" - Use write_file for new files (not bash).\n"
" - Use bash for shell commands (git, python, tests).\n"
" - Use search to find code across files.\n\n"
"3. **Complete the task fully.** Do not ask follow-up "
"questions — execute the work as described in the prompt."
),
}
skill_data = item.get("skill")
if skill_data:
# Structured forensic record naming the skill the LLM ran
# under. The approval row captures the choice at consent
# time; this log captures it at exec time so post-incident
# search ("which sessions ran skill X?") doesn't have to
# cross-walk approval and exec tables.
log.info(
"task_agent.skill_invoked",
skill=skill_data["name"],
risk_level=skill_data.get("risk_level", ""),
ws_id=self._ws_id,
)
context = {
"model": self.model,
"ws_id": self._ws_id,
"node_id": self._node_id or "",
}
persona = _render_template(skill_data["content"], context)
if len(persona) > _MAX_SKILL_CONTENT:
log.warning(
"skill_content.truncated",
length=len(persona),
agent="task",
skill=skill_data.get("name", ""),
)
persona = persona[:_MAX_SKILL_CONTENT]
else:
persona = self._TASK_DEFAULT_IDENTITY
identity = persona + "\n\n" + self._TASK_OPERATING_GUIDANCE
# Task agent gets the base system prompt (tool patterns) merged
# with its own identity in a single system message. No conversation
# history — it's an autonomous sub-agent. Merged to avoid
# multi-system-message errors on models like Qwen.
base = self._agent_system_messages[0]["content"] if self._agent_system_messages else ""
agent_messages = [
{"role": "system", "content": base + "\n\n" + task_instruction["content"]},
{"role": "system", "content": base + "\n\n" + identity},
{"role": "user", "content": prompt},
]
try:
@@ -9638,6 +9906,56 @@ class ChatSession:
return content
def _audit_memory_event(
self,
action: str,
memory_id: str,
*,
name: str,
scope: str,
scope_id: str,
mem_type: str,
) -> None:
"""Emit an audit row for a mutating memory tool action.
Closes the audit gap that previously masked out-of-band deletes
when investigating "save reports success but get returns
not-found": only the admin-console DELETE route emitted
``memory.delete`` rows, so a long-running session whose row was
deleted by the console UI couldn't tell from logs alone whether
the row had been deleted, never persisted, or was never visible.
``scope_id`` is the empty string for ``scope='global'`` and the
actor's user_id / ws_id for the other scopes — written as-is so
forensic queries can filter on it. ``ws_id`` always rides in
the detail (``self._ws_id`` is unconditional on ChatSession).
Best-effort: failures log at debug and swallow so an audit hiccup
never breaks the tool call itself. Reads (get/search/list) are
intentionally not audited they'd multiply audit volume
without forensic value.
"""
try:
from turnstone.core.audit import record_audit
detail: dict[str, Any] = {
"name": name,
"scope": scope,
"scope_id": scope_id,
"type": mem_type,
"ws_id": self._ws_id,
}
record_audit(
get_storage(),
self._user_id,
action,
"memory",
memory_id,
detail,
)
except Exception:
log.debug("memory.audit_failed action=%s name=%s", action, name, exc_info=True)
def _exec_memory(self, item: dict[str, Any]) -> tuple[str, str]:
"""Execute a memory tool action."""
call_id = item["call_id"]
@@ -9659,6 +9977,14 @@ class ChatSession:
return call_id, msg
self._invalidate_memory_cache()
self._init_system_messages()
self._audit_memory_event(
"memory.update" if old is not None else "memory.save",
memory_id,
name=item["name"],
scope=item["scope"],
scope_id=item["scope_id"],
mem_type=item["mem_type"],
)
if old is not None:
msg = f"Updated memory '{item['name']}' (type={item['mem_type']}, scope={item['scope']})"
else:
@@ -9691,20 +10017,36 @@ class ChatSession:
if action == "delete":
scopes = item["scopes_to_try"]
deleted = False
deleted: dict[str, str] | None = None
deleted_scope = ""
deleted_scope_id = ""
# Look up first so the audit row can record the deleted
# memory_id + type (delete-by-name returns only a bool).
# Falling back through the scope walk keeps the current
# narrowest-first IC semantics; coord sessions only see
# ``coordinator`` here.
for scope, scope_id in scopes:
if delete_structured_memory(item["name"], scope, scope_id):
deleted = True
existing = get_structured_memory_by_name(item["name"], scope, scope_id)
if existing and delete_structured_memory_by_id(existing["memory_id"]):
deleted = existing
deleted_scope = scope
deleted_scope_id = scope_id
break
if not deleted:
if deleted is None:
tried = ", ".join(s for s, _ in scopes)
msg = f"Error: memory '{item['name']}' not found (searched scopes: {tried})"
self._report_tool_result(call_id, "memory", msg, is_error=True)
else:
self._invalidate_memory_cache()
self._init_system_messages()
self._audit_memory_event(
"memory.delete",
deleted["memory_id"],
name=item["name"],
scope=deleted_scope,
scope_id=deleted_scope_id,
mem_type=deleted.get("type", ""),
)
msg = f"Deleted memory '{item['name']}' (scope={deleted_scope})"
self._report_tool_result(call_id, "memory", msg)
return call_id, msg
@@ -10231,16 +10573,23 @@ class ChatSession:
msg = "Error: storage unavailable"
self._report_tool_result(call_id, "watch", msg, is_error=True)
return call_id, msg
watches = storage.list_watches_for_ws(self._ws_id)
target = None
for w in watches:
if w["name"] == name or w["watch_id"].startswith(name):
target = w
break
target = storage.find_watch_by_name(self._ws_id, name)
if target is None:
msg = f'Watch "{name}" not found.'
self._report_tool_result(call_id, "watch", msg, is_error=True)
return call_id, msg
# In either branch below the row leaves ``list_due_watches``
# view (already-inactive or just-cancelled with empty
# next_poll), so the runner's retry-deactivate branch will
# never reclaim a pending ``_terminal_dispatched`` entry.
# Clear it here to bound the lifetime of any leftover from
# a previous dispatch-then-failed-row-write.
if self._watch_runner is not None:
self._watch_runner.forget_terminal_dispatched(target["watch_id"])
if not target["active"]:
msg = f'Watch "{target["name"]}" already completed (auto-cancelled).'
self._report_tool_result(call_id, "watch", msg)
return call_id, msg
storage.update_watch(target["watch_id"], active=False, next_poll="")
msg = f'Watch "{target["name"]}" cancelled.'
self._report_tool_result(call_id, "watch", msg)
+14
View File
@@ -22,6 +22,7 @@ from turnstone.core.workstream import Workstream, WorkstreamKind, WorkstreamStat
if TYPE_CHECKING:
from collections.abc import Callable
from turnstone.core.child_event_bus import ChildEventBus
from turnstone.core.session import ChatSession, SessionUI
from turnstone.core.state_writer import StateWriter
from turnstone.core.storage._protocol import StorageBackend
@@ -252,6 +253,19 @@ class SessionManager:
def kind(self) -> WorkstreamKind:
return self._adapter.kind
@property
def child_event_bus(self) -> ChildEventBus | None:
"""Delegate to the adapter's per-workstream wakeup bus.
Returns ``None`` for adapters that don't host one (today only the
coord adapter does; interactive's child surface is degenerate
and has nothing to wait on yet). Manager-level property gives
adapter-agnostic callers (tests, future cross-kind tools) a
stable lookup that doesn't depend on knowing which adapter is
attached.
"""
return getattr(self._adapter, "child_event_bus", None)
@property
def _service_type(self) -> str | None:
"""``services.service_type`` this manager's hosting process registers
+205 -18
View File
@@ -179,6 +179,24 @@ class SessionUIBase:
# ``/dashboard`` payload. Capped so a long-running skill
# workstream can't fill the live block with stale rows.
self._recent_auto_approvals: list[dict[str, Any]] = []
# Maps ``call_id`` → ``(auto_approve_reason, inserted_ts)`` for
# verdicts that arrive AFTER ``approve_tools`` already returned.
# The LLM judge tier is asynchronous: ``on_intent_verdict`` can
# fire seconds later for a tool that ``approve_tools``
# short-circuited via one of the auto-approve branches. Without
# this lookup the late-arriving LLM verdict lands with
# ``user_decision="pending"`` and stays that way forever (no
# ``resolve_approval`` cycle on the auto-approve path).
#
# Lifetime is bounded by ``_AUTO_APPROVE_REASON_TTL`` rather
# than by a count-cap or by session lifetime: a fixed cap
# would silently break the fix on the (N+1)th in-flight
# auto-approve; "evict on consume" alone would leak entries
# whenever the LLM judge is disabled (no ``on_intent_verdict``
# ever fires to drain them). TTL means entries clear lazily
# on the next ``_record_auto_approves`` write whether or not
# the LLM judge tier is active. Guarded by ``_ws_lock``.
self._auto_approve_reasons: dict[str, tuple[str, float]] = {}
# Foreground gate — used by the CLI's WorkstreamTerminalUI to
# block output when the workstream is in the background.
# Starts set so non-CLI UIs can skip any explicit management.
@@ -367,6 +385,7 @@ class SessionUIBase:
feedback: str | None = None,
*,
always: bool = False,
timeout: bool = False,
) -> None:
"""Unblock a pending approval with the caller's decision.
@@ -383,8 +402,22 @@ class SessionUIBase:
can label their resolved-status pill correctly). Keyword-only
+ default ``False`` so the four pre-existing callers (cancel,
timeout, channel adapters) compile unchanged.
``timeout`` flips the persisted ``user_decision`` from
``"denied"`` to ``"timeout"`` so the audit trail can
distinguish an active user denial from a passive
approval-timeout expiry the feedback string carries the
same information today but operators querying on the
``user_decision`` column alone could not tell them apart.
Mutually exclusive with ``approved=True`` (a timeout is a
passive denial); the combination raises ``ValueError`` so a
future caller can't accidentally ship a row whose audit
column says ``"timeout"`` while the SSE event reports
``approved=True``.
"""
decision_str = "approved" if approved else "denied"
if timeout and approved:
raise ValueError("resolve_approval: timeout=True is incompatible with approved=True")
decision_str = "timeout" if timeout else ("approved" if approved else "denied")
# Swap-and-clear + set decision under lock to avoid racing
# with the daemon judge thread's ``on_intent_verdict`` appends.
with self._ws_lock:
@@ -544,8 +577,15 @@ class SessionUIBase:
# the early return — the fall-through
# branch never runs on this path, so without
# this the policy bypass is invisible to
# /dashboard + audit.
# /dashboard + audit. ``_record_auto_approves``
# MUST run before ``_persist_auto_approved_*``
# so the call_id → reason lookup map is
# populated before the heuristic INSERTs go
# in: otherwise an LLM judge verdict firing
# in the gap lands with ``user_decision=
# "pending"`` and stays that way.
self._record_auto_approves(items)
self._persist_auto_approved_heuristic_verdicts(items)
self._enqueue(
{
"type": "tool_info",
@@ -608,7 +648,12 @@ class SessionUIBase:
self._ws_current_activity = f"{label}: {preview}" if label else ""
self._ws_activity_state = "tool" if label else ""
self._broadcast_activity()
# ``_record_auto_approves`` runs FIRST so the call_id → reason
# lookup is populated before the heuristic INSERT can race
# against a concurrent LLM judge verdict — see the matching
# comment on the policy-deny branch above.
self._record_auto_approves(items)
self._persist_auto_approved_heuristic_verdicts(items)
self._enqueue({"type": "tool_info", "items": self._serialize_approval_items(items)})
return True, None
@@ -628,19 +673,34 @@ class SessionUIBase:
# commit instead of N (was visible as time-to-render-prompt
# latency for fan-out turns); the per-item Prometheus call stays
# in the loop because it's a lock+increment, not a DB round-trip.
#
# ``user_decision`` is stamped per-verdict here so the row lands
# with a meaningful value at insert: auto-approved items
# (mixed-path case: policy allowed some, others still prompt)
# carry their auto_approve_reason directly; items still pending
# operator decision carry ``"pending"`` and get updated by
# ``resolve_approval`` on close. ``_pending_verdicts`` only
# tracks the latter — auto-approved verdicts are already final.
heuristic_verdicts: list[dict[str, Any]] = []
pending_verdicts: list[dict[str, Any]] = []
for item in items:
hv = item.get("_heuristic_verdict")
if hv:
heuristic_verdicts.append(hv)
# Subclass-overridden Prometheus surface: WebUI feeds
# the per-node /metrics endpoint, ConsoleCoordinatorUI
# feeds the console's /metrics endpoint via ConsoleMetrics.
self._record_judge_metric(hv)
if not hv:
continue
if item.get("auto_approved"):
hv["user_decision"] = item.get("auto_approve_reason", "") or "pending"
else:
hv["user_decision"] = "pending"
pending_verdicts.append(hv)
heuristic_verdicts.append(hv)
# Subclass-overridden Prometheus surface: WebUI feeds
# the per-node /metrics endpoint, ConsoleCoordinatorUI
# feeds the console's /metrics endpoint via ConsoleMetrics.
self._record_judge_metric(hv)
self._persist_intent_verdicts_bulk(heuristic_verdicts, default_tier="heuristic")
with self._ws_lock:
self._pending_verdicts = heuristic_verdicts
self._pending_verdicts = pending_verdicts
# Record any items the policy block already auto-approved
# before falling through to the prompt — without this the
@@ -676,8 +736,14 @@ class SessionUIBase:
if not self._approval_event.wait(timeout=self._APPROVAL_WAIT_TIMEOUT):
# Approval timed out (e.g., user disconnected). Deny via
# resolve_approval so verdicts and state are updated consistently.
# Feedback string derives from ``_APPROVAL_WAIT_TIMEOUT`` so the
# text follows the constant if the timeout knob moves.
log.warning("Approval timed out for ws_id=%s", self.ws_id)
self.resolve_approval(False, "Approval timed out after 1 hour")
self.resolve_approval(
False,
f"Approval timed out after {self._APPROVAL_WAIT_TIMEOUT}s",
timeout=True,
)
self._pending_approval = None
approved, feedback = self._approval_result
@@ -714,8 +780,17 @@ class SessionUIBase:
``user_decision`` immediately (if the approval already
resolved) or parks the verdict in ``_pending_verdicts`` for
``resolve_approval`` to stamp on close.
When the verdict arrives for a call_id that ``approve_tools``
already auto-approved (the LLM judge is async and can fire
seconds after the auto-approve path returned), stamp the
``auto_approve_reason`` onto the verdict before persist so
the row lands with a meaningful ``user_decision`` instead of
the default ``"pending"`` (which would never be updated for
this code path).
"""
call_id = verdict.get("call_id", "")
auto_reason = ""
if call_id:
with self._ws_lock:
if (
@@ -725,6 +800,14 @@ class SessionUIBase:
oldest_key = next(iter(self._llm_verdicts))
del self._llm_verdicts[oldest_key]
self._llm_verdicts[call_id] = verdict
# Pop (not get) — once consumed the entry isn't useful
# again; TTL pruning at the writer side keeps the
# never-consumed case bounded too.
entry = self._auto_approve_reasons.pop(call_id, None)
if entry is not None:
auto_reason = entry[0]
if auto_reason:
verdict["user_decision"] = auto_reason
self._enqueue({"type": "intent_verdict", **verdict})
# Kind-specific cross-stream broadcast — ConsoleCoordinatorUI
# overrides to push onto the cluster bus so a coord parent's
@@ -743,6 +826,17 @@ class SessionUIBase:
# WRONG decision. Storage UPDATE happens outside the lock
# on the already-resolved path — no contention with other
# ws-scoped work.
# If ``auto_reason`` was stamped above, the verdict already
# carries the final ``user_decision`` for this row. Neither
# path below applies: appending to ``_pending_verdicts`` would
# cause ``resolve_approval`` (on the manual-approval sibling
# in a mixed batch) to overwrite the auto-reason with
# ``"approved"``/``"denied"``/``"timeout"``; the
# ``_persist_verdict_decisions`` immediate-stamp path would
# overwrite it the same way from a prior cycle's decision.
# Skip both so the audit trail keeps the auto-approve reason.
if auto_reason:
return
with self._ws_lock:
decision = self._last_verdict_decision
if not decision:
@@ -811,9 +905,29 @@ class SessionUIBase:
"tier": v.get("tier", default_tier),
"judge_model": v.get("judge_model", ""),
"latency_ms": v.get("latency_ms", 0),
"user_decision": v.get("user_decision", "pending"),
}
for v in verdicts
]
# Plain INSERT (not UPSERT) at the bulk site. The race
# where a daemon-judge verdict lands BEFORE this bulk
# write IS reachable today: ``_evaluate_intent``
# (session.py) spawns the daemon thread before
# ``approve_tools`` is called, and the daemon's first
# emission (heuristic-only short batch, fast LLM response,
# or cancel-event ``_deliver_fallbacks`` from judge.py)
# can fire ``_persist_intent_verdict`` before this bulk
# INSERT runs. Outcome of that race is unchanged by the
# per-row UPSERT switch: the bulk INSERT statement aborts
# on PK collision regardless of whether the colliding row
# was planted by INSERT or UPSERT, and the wrapping
# ``try/except`` swallows it. Race A (daemon fires AFTER
# bulk) IS improved by the fix: heuristic→llm_fallback
# upgrade-in-place now lands. Future bulk-side hardening
# (``ON CONFLICT DO NOTHING``) would preserve the OTHER
# rows in the batch when one collides, but would keep the
# daemon's ``tier`` ("llm"/"llm_fallback") for the
# colliding row instead of the bulk's heuristic stamp.
storage.create_intent_verdicts_bulk(rows)
except Exception:
log.debug("Failed to bulk-persist intent verdicts", exc_info=True)
@@ -824,15 +938,21 @@ class SessionUIBase:
*,
default_tier: str = "llm",
) -> None:
"""Persist an intent-judge verdict row.
"""Persist an intent-judge verdict row via UPSERT.
Used by both the async LLM-tier path (``on_intent_verdict``,
default tier ``"llm"``) and the synchronous heuristic-tier
path (``approve_tools``, caller passes ``default_tier="heuristic"``).
Used by the async LLM-tier path (``on_intent_verdict``,
default tier ``"llm"``). Routes through ``upsert_intent_verdict``
because ``tier="llm_fallback"`` verdicts deliberately reuse the
heuristic verdict's ``verdict_id`` (see ``judge.py`` —
``_deliver_fallbacks`` and the in-loop fallback path)
so the row gets "upgraded in place" from heuristic
llm_fallback. A plain INSERT would collide on the PK and the
upgrade would be lost to a silently-swallowed exception.
``default_tier`` only matters when the verdict dict doesn't
already carry a ``tier`` key both real producers always set it,
but the fallback is the right call-site label so a malformed
verdict still lands on the correct row classification.
already carry a ``tier`` key both real producers always set
it, but the fallback is the right call-site label so a
malformed verdict still lands on the correct row
classification.
"""
try:
from turnstone.core.storage._registry import get_storage
@@ -840,7 +960,7 @@ class SessionUIBase:
storage = get_storage()
if storage is None:
return
storage.create_intent_verdict(
storage.upsert_intent_verdict(
verdict_id=verdict.get("verdict_id", ""),
ws_id=self.ws_id,
call_id=verdict.get("call_id", ""),
@@ -855,6 +975,7 @@ class SessionUIBase:
tier=verdict.get("tier", default_tier),
judge_model=verdict.get("judge_model", ""),
latency_ms=verdict.get("latency_ms", 0),
user_decision=verdict.get("user_decision", "pending"),
)
except Exception:
log.debug("Failed to persist intent verdict", exc_info=True)
@@ -955,6 +1076,14 @@ class SessionUIBase:
# workstreams that auto-approve dozens of tool calls per turn.
_RECENT_AUTO_APPROVALS_MAX = 10
# TTL on the call_id → auto_approve_reason map. Sized to comfortably
# cover the LLM judge's worst-case latency (cold start + a slow model
# + queue depth). Pruning happens lazily at write time so the cost
# is paid only on the next auto-approve event; a session that goes
# quiet after auto-approving never pays the prune cost at all but
# the resident-set is also tiny.
_AUTO_APPROVE_REASON_TTL = 60.0
@staticmethod
def _serialize_approval_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Project each item to the wire shape the SSE event payload uses.
@@ -1026,6 +1155,38 @@ class SessionUIBase:
else:
it["auto_approve_reason"] = reason
def _persist_auto_approved_heuristic_verdicts(self, items: list[dict[str, Any]]) -> None:
"""Persist heuristic verdicts for items the auto-approve path resolved.
The manual-approval block at the bottom of ``approve_tools``
handles its own verdict persistence (and stamps
``user_decision`` per item auto-approved items in a
mixed-path batch carry their reason, pending items carry
``"pending"``). The auto-approve early-return branches
(policy-allow-with-deny, blanket flag, auto_approve_tools
match) used to drop heuristic verdicts on the floor an
operator querying ``user_decision`` for the auto-approve
reason would find no row at all, conflating "we auto-approved
silently" with "the judge didn't run". This helper closes
that gap: walk ``items``, persist each auto-approved verdict
with its reason stamped, and fan a metric row per verdict.
Safe to call with empty items.
"""
if not items:
return
verdicts: list[dict[str, Any]] = []
for it in items:
if not it.get("auto_approved"):
continue
hv = it.get("_heuristic_verdict")
if not hv:
continue
hv["user_decision"] = it.get("auto_approve_reason", "") or "pending"
verdicts.append(hv)
self._record_judge_metric(hv)
if verdicts:
self._persist_intent_verdicts_bulk(verdicts, default_tier="heuristic")
def _record_auto_approves(self, items: list[dict[str, Any]]) -> None:
"""Append auto-approved items to the per-ws ring buffer + audit log.
@@ -1062,6 +1223,32 @@ class SessionUIBase:
overflow = len(self._recent_auto_approvals) - self._RECENT_AUTO_APPROVALS_MAX
if overflow > 0:
self._recent_auto_approvals = self._recent_auto_approvals[overflow:]
# Mirror call_id → reason into the lookup map so a late
# ``on_intent_verdict`` (LLM judge tier) can stamp the
# right ``user_decision`` instead of leaving the verdict
# stuck as ``"pending"`` forever. Prune expired entries
# first (lazy TTL eviction) so a session with the LLM
# judge disabled doesn't accumulate entries that will
# never be consumed. Skip the rebuild when the map is
# empty or all entries are still fresh — the common case
# on a healthy LLM-judge-enabled session where entries
# drain via ``on_intent_verdict.pop`` within the TTL.
cutoff = ts - self._AUTO_APPROVE_REASON_TTL
if self._auto_approve_reasons and any(
ins_ts < cutoff for _, ins_ts in self._auto_approve_reasons.values()
):
self._auto_approve_reasons = {
cid: (reason, ins_ts)
for cid, (reason, ins_ts) in self._auto_approve_reasons.items()
if ins_ts >= cutoff
}
for entry in appended:
cid = entry["call_id"]
if cid:
self._auto_approve_reasons[cid] = (
entry["auto_approve_reason"],
ts,
)
# Audit emission — one row per ``approve_tools`` call (not one
# per item) keeps the audit table from blowing up on
# tool-heavy turns while still capturing every tool name +
+62
View File
@@ -0,0 +1,62 @@
"""Cross-process notification primitive shared by all storage backends.
Provides a uniform ``notify`` / ``listen`` shape over PostgreSQL's
``LISTEN`` / ``NOTIFY`` and a SQLite synthetic-sweep fallback.
Consumers subscribe to one or more channels, drain a
:class:`NotifyStream` via :meth:`NotifyStream.poll`, and reconcile by
re-reading the relevant rows on every wake-up. Payloads are signal-only
(<= 8 KiB on Postgres) full event content is delivered by SSE or
in-process callbacks elsewhere; this primitive is the "go re-read these
rows" wake-up channel, nothing more.
The PostgreSQL implementation requires a session-mode connection
(``pgbouncer`` in transaction mode is incompatible with LISTEN). See
the ``listen`` docs on each backend for the deployment-config detail.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class Notify:
"""One notification draining out of a :class:`NotifyStream`."""
channel: str
payload: str
pid: int
class NotifyConnectionError(Exception):
"""Raised when a :class:`NotifyStream`'s underlying connection drops.
Consumers handle this by closing the stream, reconciling against the
relevant table (re-reading whatever rows the channel describes), and
reopening with a fresh :meth:`StorageBackend.listen` call.
"""
class NotifyStream(Protocol):
"""Bounded-blocking pull interface for cross-process notifications.
Returned by :meth:`StorageBackend.listen` as a context manager; the
consumer drains via :meth:`poll` in a loop, typically with a short
timeout so the loop can also observe a shutdown flag.
"""
def poll(self, timeout: float) -> list[Notify]:
"""Wait up to ``timeout`` seconds for notifications.
Returns the list of notifications received during the wait
(possibly empty on timeout). Raises :class:`NotifyConnectionError`
if the underlying connection was dropped the caller reconciles
and re-listens.
"""
...
def close(self) -> None:
"""Stop the stream; subsequent :meth:`poll` calls return ``[]``."""
...
+383 -7
View File
@@ -3,19 +3,23 @@
from __future__ import annotations
import contextlib
import os
import threading
import time
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Iterable, Iterator
from turnstone.core.storage._notify import Notify, NotifyStream
import sqlalchemy as sa
from turnstone.core.log import get_logger
from turnstone.core.storage._protocol import (
MCPOAuthPendingState,
MCPPendingConsentRow,
MCPUserToken,
MCPUserTokenMetadataRow,
OIDCIdentity,
@@ -30,6 +34,7 @@ from turnstone.core.storage._schema import (
heuristic_rules,
intent_verdicts,
mcp_oauth_pending,
mcp_pending_consent,
mcp_servers,
mcp_user_tokens,
metadata,
@@ -67,6 +72,9 @@ from turnstone.core.storage._schema import (
from turnstone.core.storage._utils import (
HEURISTIC_RULE_MUTABLE as _HEURISTIC_RULE_MUTABLE,
)
from turnstone.core.storage._utils import (
LIKE_ESCAPE as _LIKE_ESCAPE,
)
from turnstone.core.storage._utils import (
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
)
@@ -97,6 +105,9 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
VERDICT_MUTABLE as _VERDICT_MUTABLE,
)
from turnstone.core.storage._utils import (
escape_like as _escape_like,
)
from turnstone.core.storage._utils import (
normalize_search_terms as _normalize_search_terms,
)
@@ -115,16 +126,105 @@ from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
log = get_logger(__name__)
def _escape_ilike(s: str) -> str:
"""Escape ILIKE metacharacters for use with ESCAPE '\\\\'."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _resolve_pg_listen_url(override: str, sqlalchemy_url: str) -> str:
"""Resolve the URL used by the dedicated LISTEN connection.
Precedence:
1. ``override`` explicitly passed via :class:`PostgreSQLBackend`
constructor, typically wired from ``[database] listen_url`` in
``config.toml`` or ``--db-listen-url`` on the CLI.
2. ``TURNSTONE_DB_LISTEN_URL`` environment variable.
3. The engine's main DB URL.
The override is for deployments where the regular ``TURNSTONE_DB_URL``
points at a ``pgbouncer`` running in transaction pooling mode (the
project default per ``docs/pgbouncer.md``). LISTEN holds session
state and is incompatible with transaction pooling; the dispatcher
needs to bypass pgbouncer for that single connection. When neither
override is set and ``TURNSTONE_DB_URL`` already points at Postgres
directly (no pooler in between), the fallback uses the engine URL
as-is.
The SQLAlchemy ``+psycopg`` driver suffix is stripped so the URL is
consumable by ``psycopg.connect`` directly.
"""
raw = override.strip() or os.environ.get("TURNSTONE_DB_LISTEN_URL", "").strip()
raw = raw or sqlalchemy_url
return raw.replace("postgresql+psycopg://", "postgresql://", 1)
class _PostgreSQLNotifyStream:
"""PostgreSQL ``listen`` stream — drains ``conn.notifies`` per poll.
Owns a dedicated psycopg autocommit connection. Each :meth:`poll`
waits up to ``timeout`` seconds for notifications and returns them
as a list empty on timeout, raises :class:`NotifyConnectionError`
on connection loss (caller reconciles + re-listens).
Closing the stream from another thread is the supported abort path:
``close`` calls ``conn.close()``, which causes the in-flight
:meth:`poll` to wake (the next call returns ``[]`` because
``_closed`` is set).
"""
def __init__(self, conn: Any, channels: list[str]) -> None:
self._conn = conn
self._channels = list(channels)
self._closed = False
self._close_lock = threading.Lock()
def poll(self, timeout: float) -> list[Notify]:
from turnstone.core.storage._notify import Notify, NotifyConnectionError
if self._closed:
return []
out: list[Notify] = []
try:
# psycopg3 generator yields whatever's available within the
# window, then stops — bounded blocking semantics. Per-call
# generator (not a long-lived one) so close() can abort by
# closing the connection without leaving a half-consumed
# generator behind.
for n in self._conn.notifies(timeout=max(0.0, timeout)):
out.append(Notify(channel=n.channel, payload=n.payload, pid=n.pid))
except Exception as exc:
if self._closed:
# Graceful close-from-another-thread surfaced as an
# operational error inside notifies() — swallow it,
# let the caller observe close via the next poll
# returning ``[]``.
return out
raise NotifyConnectionError(str(exc)) from exc
return out
def close(self) -> None:
with self._close_lock:
if self._closed:
return
self._closed = True
conn = self._conn
# Best-effort UNLISTEN + close. An already-broken connection
# raises here; the consumer's reconciliation logic will catch
# the underlying ``NotifyConnectionError`` on the next poll if
# any waiter is still blocked.
with contextlib.suppress(Exception):
conn.execute("UNLISTEN *")
with contextlib.suppress(Exception):
conn.close()
class PostgreSQLBackend:
"""PostgreSQL implementation of the StorageBackend protocol."""
def __init__(
self, url: str, pool_size: int = 2, max_overflow: int = 3, *, create_tables: bool = True
self,
url: str,
pool_size: int = 2,
max_overflow: int = 3,
*,
create_tables: bool = True,
listen_url: str = "",
) -> None:
self._engine = sa.create_engine(
url,
@@ -134,6 +234,12 @@ class PostgreSQLBackend:
)
self._db_unavailable = False
self._db_unavailable_lock = threading.Lock()
# Operator override for the dedicated LISTEN connection's URL.
# Empty string means "fall back through env var, then the main
# engine URL" — see :func:`_resolve_pg_listen_url` for the full
# precedence rules. Threaded through ``init_storage`` from
# ``config.toml [database] listen_url`` / ``--db-listen-url``.
self._listen_url_override = listen_url
if create_tables:
metadata.create_all(self._engine)
@@ -1730,6 +1836,33 @@ class PostgreSQLBackend:
).fetchall()
return [dict(r._mapping) for r in rows]
def find_watch_by_name(self, ws_id: str, name_or_prefix: str) -> dict[str, Any] | None:
if not name_or_prefix:
return None
like_pattern = _escape_like(name_or_prefix) + "%"
with self._conn() as conn:
row = conn.execute(
sa.select(watches)
.where(
(watches.c.ws_id == ws_id)
& (
(watches.c.name == name_or_prefix)
| watches.c.watch_id.like(like_pattern, escape=_LIKE_ESCAPE)
)
)
# Active rows win over inactive ones with the same name.
# _prepare_watch's duplicate-name guard filters active=1,
# so a model can recreate a name after the previous one
# auto-cancelled; a cancel-by-name request on the live
# row must not be shadowed by the older completed row.
.order_by(watches.c.active.desc(), watches.c.created.desc())
.limit(1)
).fetchone()
if row is None:
return None
return dict(row._mapping)
def list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
with self._conn() as conn:
@@ -1863,6 +1996,61 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount > 0
# -- Cross-process notifications -------------------------------------------
def notify(self, channel: str, payload: str = "") -> None:
"""Broadcast a wake-up via ``pg_notify`` on a pooled connection.
``channel`` and ``payload`` are bound as parameters so this is
safe to call with operator-supplied strings without quoting
gymnastics. Postgres caps the payload at 8 KiB keep payloads
signal-only (a JSON id list, an op name) and let consumers
re-read the underlying rows on wake-up.
"""
with self._conn() as conn:
conn.execute(
sa.text("SELECT pg_notify(:channel, :payload)"),
{"channel": channel, "payload": payload},
)
conn.commit()
@contextlib.contextmanager
def listen(self, channels: Iterable[str]) -> Iterator[NotifyStream]:
"""Subscribe to channels on a dedicated session-mode connection.
Opens a fresh ``psycopg`` connection in autocommit mode (the
SQLAlchemy pool is incompatible with LISTEN it recycles
connections back into a pool that may be transaction-pooled by
pgbouncer). Channel names are interpolated via
``psycopg.sql.Identifier`` so caller-supplied channel strings
can't inject SQL.
``TURNSTONE_DB_LISTEN_URL`` overrides the engine URL see
:func:`_resolve_pg_listen_url` for the bypass-URL rationale.
Yields a :class:`_PostgreSQLNotifyStream`; the connection is
closed on context exit.
"""
import psycopg
from psycopg import sql
ch_list = [str(c) for c in channels if c]
sqlalchemy_url = self._engine.url.render_as_string(hide_password=False)
listen_url = _resolve_pg_listen_url(self._listen_url_override, sqlalchemy_url)
conn = psycopg.connect(listen_url, autocommit=True)
stream: _PostgreSQLNotifyStream | None = None
try:
for ch in ch_list:
conn.execute(sql.SQL("LISTEN {}").format(sql.Identifier(ch)))
stream = _PostgreSQLNotifyStream(conn, ch_list)
yield stream
finally:
if stream is not None:
stream.close()
else:
with contextlib.suppress(Exception):
conn.close()
# -- Node metadata ---------------------------------------------------------
def get_node_metadata(self, node_id: str) -> list[dict[str, Any]]:
@@ -3174,6 +3362,7 @@ class PostgreSQLBackend:
tier: str,
judge_model: str,
latency_ms: int,
user_decision: str = "pending",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
@@ -3194,11 +3383,67 @@ class PostgreSQLBackend:
"tier": tier,
"judge_model": judge_model,
"latency_ms": latency_ms,
"user_decision": user_decision,
"created": now,
},
)
conn.commit()
def upsert_intent_verdict(
self,
verdict_id: str,
ws_id: str,
call_id: str,
func_name: str,
func_args: str,
intent_summary: str,
risk_level: str,
confidence: float,
recommendation: str,
reasoning: str,
evidence: str,
tier: str,
judge_model: str,
latency_ms: int,
user_decision: str = "pending",
) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = pg_insert(intent_verdicts).values(
verdict_id=verdict_id,
ws_id=ws_id,
call_id=call_id,
func_name=func_name,
func_args=func_args,
intent_summary=intent_summary,
risk_level=risk_level,
confidence=confidence,
recommendation=recommendation,
reasoning=reasoning,
evidence=evidence,
tier=tier,
judge_model=judge_model,
latency_ms=latency_ms,
user_decision=user_decision,
created=now,
)
# On verdict_id conflict, update only the three fields that
# genuinely change between heuristic and llm_fallback. See the
# protocol docstring for the full exclusion rationale —
# ``user_decision`` exclusion in particular is load-bearing.
stmt = stmt.on_conflict_do_update(
index_elements=[intent_verdicts.c.verdict_id],
set_={
"tier": tier,
"reasoning": reasoning,
"judge_model": judge_model,
},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def create_intent_verdicts_bulk(self, verdicts: list[dict[str, Any]]) -> None:
if not verdicts:
return
@@ -3219,6 +3464,7 @@ class PostgreSQLBackend:
"tier": v.get("tier", "heuristic"),
"judge_model": v.get("judge_model", ""),
"latency_ms": v.get("latency_ms", 0),
"user_decision": v.get("user_decision", "pending"),
"created": now,
}
for v in verdicts
@@ -3511,7 +3757,7 @@ class PostgreSQLBackend:
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
escaped = _escape_ilike(t)
escaped = _escape_like(t)
clauses.append(
f"(name ILIKE :n{i} ESCAPE '\\' "
f"OR description ILIKE :d{i} ESCAPE '\\' "
@@ -3590,7 +3836,7 @@ class PostgreSQLBackend:
scope_clauses, params = self._build_scope_or_clause(scopes)
term_clauses = []
for i, t in enumerate(terms):
escaped = _escape_ilike(t)
escaped = _escape_like(t)
term_clauses.append(
f"(name ILIKE :n{i} ESCAPE '\\' "
f"OR description ILIKE :d{i} ESCAPE '\\' "
@@ -4150,6 +4396,136 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount
# -- MCP pending-consent (Phase 9) ----------------------------------------
def upsert_mcp_pending_consent(
self,
user_id: str,
server_name: str,
error_code: str,
scopes_required: str | None,
last_ws_id: str | None,
last_tool_call_id: str | None,
now_iso: str,
) -> None:
from sqlalchemy.dialects import postgresql
stmt = postgresql.insert(mcp_pending_consent).values(
user_id=user_id,
server_name=server_name,
error_code=error_code,
scopes_required=scopes_required,
last_ws_id=last_ws_id,
last_tool_call_id=last_tool_call_id,
first_seen_at=now_iso,
last_seen_at=now_iso,
occurrence_count=1,
)
stmt = stmt.on_conflict_do_update(
index_elements=["user_id", "server_name"],
set_={
"error_code": stmt.excluded.error_code,
"scopes_required": stmt.excluded.scopes_required,
"last_ws_id": stmt.excluded.last_ws_id,
"last_tool_call_id": stmt.excluded.last_tool_call_id,
"last_seen_at": stmt.excluded.last_seen_at,
"occurrence_count": mcp_pending_consent.c.occurrence_count + 1,
},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def list_mcp_pending_consent_by_user(self, user_id: str) -> list[MCPPendingConsentRow]:
with self._conn() as conn:
rows = conn.execute(
sa.select(mcp_pending_consent)
.where(mcp_pending_consent.c.user_id == user_id)
.order_by(mcp_pending_consent.c.last_seen_at.desc())
).fetchall()
out: list[MCPPendingConsentRow] = []
for r in rows:
m = r._mapping
out.append(
MCPPendingConsentRow(
user_id=m["user_id"],
server_name=m["server_name"],
error_code=m["error_code"],
scopes_required=m["scopes_required"],
last_ws_id=m["last_ws_id"],
last_tool_call_id=m["last_tool_call_id"],
first_seen_at=m["first_seen_at"],
last_seen_at=m["last_seen_at"],
occurrence_count=m["occurrence_count"],
)
)
return out
def delete_mcp_pending_consent(self, user_id: str, server_name: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(mcp_pending_consent).where(
(mcp_pending_consent.c.user_id == user_id)
& (mcp_pending_consent.c.server_name == server_name)
)
)
conn.commit()
return bool(result.rowcount)
def delete_all_mcp_pending_consent_by_user(self, user_id: str) -> int:
with self._conn() as conn:
result = conn.execute(
sa.delete(mcp_pending_consent).where(mcp_pending_consent.c.user_id == user_id)
)
conn.commit()
return int(result.rowcount or 0)
def count_mcp_consented_users_by_server(self, server_name: str) -> int:
# ``expires_at IS NULL`` => non-expired (refresh-only tokens with no
# advertised expiry). Compare lexically against ISO-8601 strings,
# mirroring the convention in ``mcp_user_tokens.expires_at``.
now_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.select(sa.func.count(sa.distinct(mcp_user_tokens.c.user_id)))
.where(mcp_user_tokens.c.server_name == server_name)
.where(
sa.or_(
mcp_user_tokens.c.expires_at.is_(None),
mcp_user_tokens.c.expires_at > now_iso,
)
)
).scalar()
return int(result or 0)
def count_mcp_consented_users_grouped_by_server(self) -> dict[str, int]:
now_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
rows = conn.execute(
sa.select(
mcp_user_tokens.c.server_name,
sa.func.count(sa.distinct(mcp_user_tokens.c.user_id)),
)
.where(
sa.or_(
mcp_user_tokens.c.expires_at.is_(None),
mcp_user_tokens.c.expires_at > now_iso,
)
)
.group_by(mcp_user_tokens.c.server_name)
).fetchall()
return {row[0]: int(row[1] or 0) for row in rows}
def any_oauth_user_mcp_servers(self) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.select(sa.literal(1))
.select_from(mcp_servers)
.where(mcp_servers.c.auth_type == "oauth_user")
.limit(1)
).scalar()
return result is not None
# -- Model definitions -----------------------------------------------------
def create_model_definition(
+226 -5
View File
@@ -5,8 +5,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol, TypedDict, runtime_checkable
if TYPE_CHECKING:
from collections.abc import Iterable
from contextlib import AbstractContextManager
from turnstone.core.storage._notify import NotifyStream
from turnstone.core.workstream import WorkstreamKind
@@ -91,6 +93,27 @@ class MCPOAuthPendingState(TypedDict):
created_at: str
class MCPPendingConsentRow(TypedDict):
"""Row shape for deferred-consent records.
Emitted by the pool dispatchers (Phase 5+) when a non-interactive
run (scheduled / channel) hits ``mcp_consent_required`` or
``mcp_insufficient_scope`` and the user can't be prompted in the
moment. Composite PK ``(user_id, server_name)`` collapses repeat
occurrences for the same server into one row.
"""
user_id: str
server_name: str
error_code: str
scopes_required: str | None
last_ws_id: str | None
last_tool_call_id: str | None
first_seen_at: str
last_seen_at: str
occurrence_count: int
@runtime_checkable
class StorageBackend(Protocol):
"""Protocol that every storage backend adapter must implement.
@@ -980,6 +1003,23 @@ class StorageBackend(Protocol):
"""Return active watches for a workstream, ordered by created DESC."""
...
def find_watch_by_name(self, ws_id: str, name_or_prefix: str) -> dict[str, Any] | None:
"""Return a watch in ``ws_id`` whose ``name`` matches
``name_or_prefix`` exactly, or whose ``watch_id`` starts with it.
Unlike :meth:`list_watches_for_ws` this DOES NOT filter on the
``active`` flag callers can inspect ``row["active"]`` to
distinguish a still-running watch from one that fired and
auto-cancelled. Returns ``None`` if no match.
When multiple rows match, prefers active rows over inactive
ones, then most-recently-created. Without the active
preference, a recreated-after-completion name would let the
older inactive row shadow the new active one in the cancel
path.
"""
...
def list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
"""Return all active watches on a node, ordered by created DESC."""
...
@@ -1020,6 +1060,33 @@ class StorageBackend(Protocol):
"""Remove a service registration. Returns True if existed."""
...
# -- Cross-process notifications -------------------------------------------
def notify(self, channel: str, payload: str = "") -> None:
"""Broadcast a wake-up on ``channel`` to any listening process.
Payloads are signal-only a JSON-encoded string identifying
which rows to re-read, capped well below Postgres's 8 KiB
``NOTIFY`` payload limit. Full event content is NOT delivered
this way; consumers reconcile by reading the relevant table on
wake-up. Safe to call from any thread.
"""
...
def listen(self, channels: Iterable[str]) -> AbstractContextManager[NotifyStream]:
"""Subscribe to one or more channels for cross-process wake-ups.
Returns a context manager wrapping a :class:`NotifyStream` the
caller drains via :meth:`NotifyStream.poll`. PostgreSQL holds a
dedicated session-mode connection for the lifetime of the
context (incompatible with ``pgbouncer`` transaction pooling
see :class:`PostgreSQLBackend.listen` for the bypass-URL config).
SQLite emits a synthetic-sweep wake on its own cadence (see
``_SQLITE_NOTIFY_SWEEP_INTERVAL``) per subscribed channel so
consumer code is identical across backends.
"""
...
# -- Node metadata ---------------------------------------------------------
def get_node_metadata(self, node_id: str) -> list[dict[str, Any]]:
@@ -1540,8 +1607,77 @@ class StorageBackend(Protocol):
tier: str,
judge_model: str,
latency_ms: int,
user_decision: str = "pending",
) -> None:
"""Record an intent validation verdict."""
"""Record an intent validation verdict.
``user_decision`` defaults to ``"pending"`` rather than ``""``
so an audit reader can distinguish "in-flight" rows from
legacy pre-fix rows (which carry ``""`` from the column's
server_default and indicate "convention not yet established
when this row was written"). Resolution writers
(:meth:`update_intent_verdict`) later overwrite the field with
``"approved"`` / ``"denied"`` / ``"timeout"`` (user-driven) or
``"policy"`` / ``"blanket"`` / ``"auto_approve_tools"``
(auto-approve reason, mirroring :class:`AutoApproveReason`).
"""
...
def upsert_intent_verdict(
self,
verdict_id: str,
ws_id: str,
call_id: str,
func_name: str,
func_args: str,
intent_summary: str,
risk_level: str,
confidence: float,
recommendation: str,
reasoning: str,
evidence: str,
tier: str,
judge_model: str,
latency_ms: int,
user_decision: str = "pending",
) -> None:
"""INSERT a verdict row, or UPDATE the judge-output fields on conflict.
Async LLM judge verdicts with ``tier="llm_fallback"`` deliberately
reuse the heuristic verdict's ``verdict_id`` so the row gets
"upgraded in place" from heuristic fallback when the LLM tier
doesn't return a real verdict (timeout / cancelled / no-content).
A plain INSERT collides on ``intent_verdicts_pkey``; this method
``ON CONFLICT (verdict_id) DO UPDATE`` updates only the columns
that genuinely change between the two tiers:
- ``tier`` (the upgrade itself)
- ``reasoning`` (gets " (LLM judge did not return a verdict)" appended)
- ``judge_model`` (heuristic carries "", fallback carries the model)
Every other column is EXCLUDED from the on-conflict SET clause:
- Identity columns (``verdict_id``, ``ws_id``, ``call_id``,
``func_name``, ``func_args``) already the same row.
- Carried-verbatim columns (``intent_summary``, ``risk_level``,
``confidence``, ``recommendation``, ``evidence``, ``latency_ms``)
the fallback copies them from the heuristic verdict; updating
would be a no-op.
- ``user_decision`` LOAD-BEARING exclusion. ``IntentVerdict.to_dict()``
doesn't project it, so a fallback verdict reaching this layer
defaults the kwarg to ``"pending"``. If the operator already
resolved the approval between heuristic INSERT and fallback
fire, the row's ``user_decision`` was already updated to
``"approved"``/``"denied"``/``"timeout"`` (or stamped to an
auto-approve reason at heuristic-INSERT time). Clobbering it
back to ``"pending"`` would undo that.
- ``created`` preserve the original timestamp.
Used by :meth:`SessionUIBase._persist_intent_verdict` for every
async LLM-tier delivery; the synchronous heuristic-bulk path
(:meth:`create_intent_verdicts_bulk`) stays as plain INSERT
since each heuristic UUID is freshly generated per turn.
"""
...
def create_intent_verdicts_bulk(self, verdicts: list[dict[str, Any]]) -> None:
@@ -1551,10 +1687,12 @@ class StorageBackend(Protocol):
(``verdict_id`` / ``ws_id`` / ``call_id`` / ``func_name`` /
``func_args`` / ``intent_summary`` / ``risk_level`` /
``confidence`` / ``recommendation`` / ``reasoning`` / ``evidence`` /
``tier`` / ``judge_model`` / ``latency_ms``). Used by the
synchronous heuristic-verdict persistence loop in
``approve_tools`` so a tool-heavy turn doesn't pay N×commit
latency before the approval prompt renders.
``tier`` / ``judge_model`` / ``latency_ms`` /
``user_decision``). ``user_decision`` defaults to ``"pending"``
when absent see :meth:`create_intent_verdict` for the
vocabulary. Used by the synchronous heuristic-verdict
persistence loop in ``approve_tools`` so a tool-heavy turn
doesn't pay N×commit latency before the approval prompt renders.
"""
...
@@ -1836,6 +1974,89 @@ class StorageBackend(Protocol):
"""Bulk-delete expired pending MCP OAuth rows. Returns count deleted."""
...
# -- MCP pending-consent (Phase 9; deferred-consent persistence) ----------
def upsert_mcp_pending_consent(
self,
user_id: str,
server_name: str,
error_code: str,
scopes_required: str | None,
last_ws_id: str | None,
last_tool_call_id: str | None,
now_iso: str,
) -> None:
"""Insert or refresh a deferred-consent record for ``(user, server)``.
On insert: ``first_seen_at = last_seen_at = now_iso``,
``occurrence_count = 1``. On conflict (existing row for the
same composite PK): rewrites ``error_code``, ``scopes_required``,
``last_ws_id``, ``last_tool_call_id``, ``last_seen_at`` to the
current values; bumps ``occurrence_count`` by 1. Preserves
``first_seen_at`` so the dashboard can show how long the
deferred-consent need has been pending.
"""
...
def list_mcp_pending_consent_by_user(self, user_id: str) -> list[MCPPendingConsentRow]:
"""Return all deferred-consent records for ``user_id``.
Ordered by ``last_seen_at`` DESC. Empty list when the user has
none. Used by the dashboard badge endpoint to render the
servers-need-consent list.
"""
...
def delete_mcp_pending_consent(self, user_id: str, server_name: str) -> bool:
"""Delete the pending-consent row for ``(user, server)``. Returns True if existed.
Called automatically by the OAuth callback handler when consent
completes, and manually via the user-facing DELETE endpoint.
"""
...
def delete_all_mcp_pending_consent_by_user(self, user_id: str) -> int:
"""Bulk-delete every pending-consent row for ``user_id``. Returns count.
Used by the manual "dismiss all" endpoint from the settings
modal.
"""
...
def count_mcp_consented_users_by_server(self, server_name: str) -> int:
"""Distinct-user count of non-expired tokens for ``server_name``.
``expires_at IS NULL`` is treated as non-expired (refresh-only
tokens with no advertised expiry). Used by the admin status
indicator to show "N users consented" per MCP server row.
"""
...
def count_mcp_consented_users_grouped_by_server(self) -> dict[str, int]:
"""Bulk distinct-user count of non-expired tokens, grouped by server.
Single round-trip variant of
:meth:`count_mcp_consented_users_by_server` for the admin list
handler replaces the N-call loop that issued one query per
server with one ``GROUP BY`` query returning ``{server_name:
count}`` for every server that has at least one non-expired
token. Servers with zero consented users are absent from the
result; callers should ``dict.get(name, 0)`` rather than
indexing.
"""
...
def any_oauth_user_mcp_servers(self) -> bool:
"""Install-level gate for OAuth-MCP features.
Returns True iff at least one ``mcp_servers`` row has
``auth_type='oauth_user'``. Used to short-circuit the pending-
consent badge endpoint to ``{pending: 0}`` on local-auth installs
with no OAuth MCP servers, so those code paths exercise zero new
storage queries.
"""
...
# -- Model definitions -----------------------------------------------------
def create_model_definition(
+14 -1
View File
@@ -34,6 +34,7 @@ def init_storage(
sslrootcert: str = "",
sslcert: str = "",
sslkey: str = "",
listen_url: str = "",
) -> StorageBackend:
"""Initialize the storage backend singleton.
@@ -43,6 +44,13 @@ def init_storage(
url: PostgreSQL connection URL (e.g. postgresql+psycopg://user:pass@host/db)
pool_size: Connection pool size (PostgreSQL only)
run_migrations: Whether to run Alembic migrations on init
listen_url: Optional dedicated PostgreSQL URL for the dispatcher's
``LISTEN`` connection. Required only when ``url`` points at a
``pgbouncer`` running in transaction pooling mode (LISTEN
holds session state and is incompatible with transaction
pooling see ``docs/pgbouncer.md``). Empty string means
"fall back through ``TURNSTONE_DB_LISTEN_URL`` env var, then
the main ``url``." Ignored on SQLite.
"""
global _storage
@@ -84,7 +92,12 @@ def init_storage(
sep = "&" if "?" in url else "?"
url += sep + urlencode(ssl_params)
_storage = PostgreSQLBackend(url, pool_size=pool_size, create_tables=create_tables)
_storage = PostgreSQLBackend(
url,
pool_size=pool_size,
create_tables=create_tables,
listen_url=listen_url,
)
log.info("Storage initialized: PostgreSQL")
else:
+105
View File
@@ -251,6 +251,76 @@ services = sa.Table(
sa.Index("idx_services_type_heartbeat", services.c.service_type, services.c.last_heartbeat)
# -- Postgres NOTIFY trigger on services -----------------------------------
#
# Producer side of the ``services`` channel that the console
# ``NotifyDispatcher`` listens on for reactive node discovery. Fires on
# real registry changes (INSERT, DELETE, UPDATE that changes ``url`` or
# ``metadata``) and stays quiet on heartbeat-only UPDATEs so the 30s × N
# nodes heartbeat tick doesn't flood the channel.
#
# Declared in the schema (not just in migration 053) so the ``after_create``
# DDL event installs the trigger any time ``metadata.create_all`` builds
# the ``services`` table — covering fresh dev databases and the test
# fixture path (``run_migrations=False``). Migration 053 covers the
# upgrade-on-existing-DB path; the two are mutually exclusive given the
# ``create_tables = not run_migrations`` switch in ``init_storage``, so
# neither double-installs. SQLite has no equivalent — the in-process
# notify fan-out and synthetic-sweep covers the dev path consumer-side.
SERVICES_NOTIFY_TRIGGER_FN_NAME = "turnstone_notify_services"
SERVICES_NOTIFY_TRIGGER_NAME = "services_notify"
SERVICES_NOTIFY_TRIGGER_FN_SQL = f"""
CREATE OR REPLACE FUNCTION {SERVICES_NOTIFY_TRIGGER_FN_NAME}() RETURNS trigger AS $$
BEGIN
-- Skip heartbeat-only UPDATEs: same url and metadata, only
-- ``last_heartbeat`` changed. ``register_service`` is an UPSERT
-- (on_conflict_do_update), so node restarts that change url or
-- metadata MUST still fire only no-op heartbeat ticks stay
-- quiet. IS NOT DISTINCT FROM treats NULLs as equal so a row
-- with NULL metadata before/after doesn't trip the diff.
IF TG_OP = 'UPDATE'
AND OLD.url IS NOT DISTINCT FROM NEW.url
AND OLD.metadata IS NOT DISTINCT FROM NEW.metadata THEN
RETURN NULL;
END IF;
PERFORM pg_notify(
'services',
json_build_object(
'service_type', COALESCE(NEW.service_type, OLD.service_type),
'service_id', COALESCE(NEW.service_id, OLD.service_id),
'op', TG_OP
)::text
);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
SERVICES_NOTIFY_TRIGGER_SQL = f"""
CREATE TRIGGER {SERVICES_NOTIFY_TRIGGER_NAME}
AFTER INSERT OR UPDATE OR DELETE ON services
FOR EACH ROW EXECUTE FUNCTION {SERVICES_NOTIFY_TRIGGER_FN_NAME}();
"""
sa.event.listen(
services,
"after_create",
sa.DDL(SERVICES_NOTIFY_TRIGGER_FN_SQL).execute_if( # type: ignore[no-untyped-call]
dialect="postgresql"
),
)
sa.event.listen(
services,
"after_create",
sa.DDL(SERVICES_NOTIFY_TRIGGER_SQL).execute_if( # type: ignore[no-untyped-call]
dialect="postgresql"
),
)
# ---------------------------------------------------------------------------
# Node metadata (per-node key/value with source tracking)
# ---------------------------------------------------------------------------
@@ -737,6 +807,15 @@ mcp_user_tokens = sa.Table(
sa.Column("last_refreshed", sa.Text, nullable=True),
sa.PrimaryKeyConstraint("user_id", "server_name"),
)
# Phase 9: covers the ``WHERE server_name = ? AND (expires_at IS NULL
# OR expires_at > now)`` shape used by ``count_mcp_consented_users_*``
# for the admin status pill. The composite PK can't satisfy filters
# that don't lead with ``user_id``.
sa.Index(
"idx_mcp_user_tokens_server",
mcp_user_tokens.c.server_name,
mcp_user_tokens.c.expires_at,
)
mcp_oauth_pending = sa.Table(
"mcp_oauth_pending",
@@ -751,6 +830,32 @@ mcp_oauth_pending = sa.Table(
sa.Index("idx_mcp_pending_created", mcp_oauth_pending.c.created_at)
# Per-(user, server) pending-consent state for non-interactive contexts.
# Populated by the pool dispatchers when a scheduled / channel-driven run
# hits ``mcp_consent_required`` or ``mcp_insufficient_scope`` and the user
# can't be prompted in the moment. Read on dashboard load to render the
# "N MCP servers need consent" badge. Cleared by the OAuth callback when
# the matching ``(user, server)`` completes consent.
#
# Composite PK ``(user_id, server_name)`` collapses repeat occurrences for
# the same server into one row; ``occurrence_count`` + ``last_*`` fields
# carry recency metadata for the dashboard without inflating row count.
mcp_pending_consent = sa.Table(
"mcp_pending_consent",
metadata,
sa.Column("user_id", sa.Text, nullable=False),
sa.Column("server_name", sa.Text, nullable=False),
sa.Column("error_code", sa.Text, nullable=False),
sa.Column("scopes_required", sa.Text, nullable=True),
sa.Column("last_ws_id", sa.Text, nullable=True),
sa.Column("last_tool_call_id", sa.Text, nullable=True),
sa.Column("first_seen_at", sa.Text, nullable=False),
sa.Column("last_seen_at", sa.Text, nullable=False),
sa.Column("occurrence_count", sa.Integer, nullable=False, server_default="1"),
sa.PrimaryKeyConstraint("user_id", "server_name"),
)
sa.Index("idx_mcp_pending_consent_user", mcp_pending_consent.c.user_id)
# ── TLS / ACME (lacme integration) ──────────────────────────────────────────
tls_account_keys = sa.Table(
+387 -6
View File
@@ -3,18 +3,23 @@
from __future__ import annotations
import contextlib
import queue
import threading
import time
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
import sqlalchemy as sa
if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Iterable, Iterator
from turnstone.core.storage._notify import Notify, NotifyStream
from turnstone.core.log import get_logger
from turnstone.core.storage._protocol import (
MCPOAuthPendingState,
MCPPendingConsentRow,
MCPUserToken,
MCPUserTokenMetadataRow,
OIDCIdentity,
@@ -29,6 +34,7 @@ from turnstone.core.storage._schema import (
heuristic_rules,
intent_verdicts,
mcp_oauth_pending,
mcp_pending_consent,
mcp_servers,
mcp_user_tokens,
metadata,
@@ -66,6 +72,9 @@ from turnstone.core.storage._schema import (
from turnstone.core.storage._utils import (
HEURISTIC_RULE_MUTABLE as _HEURISTIC_RULE_MUTABLE,
)
from turnstone.core.storage._utils import (
LIKE_ESCAPE as _LIKE_ESCAPE,
)
from turnstone.core.storage._utils import (
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
)
@@ -96,6 +105,9 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
VERDICT_MUTABLE as _VERDICT_MUTABLE,
)
from turnstone.core.storage._utils import (
escape_like as _escape_like,
)
from turnstone.core.storage._utils import (
normalize_search_terms as _normalize_search_terms,
)
@@ -114,11 +126,6 @@ from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
log = get_logger(__name__)
def _escape_like(s: str) -> str:
"""Escape LIKE metacharacters for use with ESCAPE '\\\\'."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _fts5_query(query: str) -> str:
"""Convert a plain search string into a safe FTS5 query."""
terms = query.split()
@@ -129,6 +136,90 @@ def _fts5_query(query: str) -> str:
return " ".join(safe)
# Synthetic-sweep cadence for the SQLite ``listen`` fallback. SQLite is
# the dev-only path where reactive latency isn't load-bearing — a single
# console process, no cross-process notify semantics to recover from.
# 300 s sits comfortably above the existing per-consumer timers
# (cluster collector's 60 s ``discovery_interval``, any future
# ConfigStore/scheduler reload cadences) so the sweep is a true backstop
# rather than a duplicate tick. Future consumers that need tighter
# SQLite-mode reactive latency should pass a custom interval through
# :meth:`SQLiteBackend.listen` rather than lowering this default.
_SQLITE_NOTIFY_SWEEP_INTERVAL: float = 300.0
class _SQLiteNotifyStream:
"""SQLite ``listen`` stream — synthetic sweep + in-process fan-out.
Each poll either drains queued in-process notifies (delivered by a
same-process :meth:`SQLiteBackend.notify` call) or emits one
synthetic ``Notify(channel, payload="sweep", pid=0)`` per subscribed
channel once :attr:`_sweep_interval` has elapsed since the previous
sweep, whichever happens first. Consumers handle both shapes the
same way: re-read the relevant rows on every wake-up.
"""
def __init__(
self,
backend: SQLiteBackend,
channels: list[str],
sweep_interval: float,
) -> None:
self._backend = backend
self._channels = list(channels)
self._sweep_interval = sweep_interval
self._queue: queue.Queue[Any] = queue.Queue()
self._closed = False
self._last_sweep = time.monotonic()
if self._channels:
backend._notify_register(self._channels, self._queue)
def poll(self, timeout: float) -> list[Notify]:
from turnstone.core.storage._notify import Notify
if self._closed:
return []
deadline = time.monotonic() + max(0.0, timeout)
# Emit a synthetic-sweep tick on the first poll where the sweep
# interval has elapsed. Single tick per channel per interval —
# PG-equivalent "one wake-up per change" semantics, not a burst.
now = time.monotonic()
if self._channels and now - self._last_sweep >= self._sweep_interval:
self._last_sweep = now
for ch in self._channels:
with contextlib.suppress(Exception):
self._queue.put_nowait(Notify(channel=ch, payload="sweep", pid=0))
out: list[Notify] = []
try:
while True:
if self._closed:
break
if out:
# Drain everything already queued without further
# blocking — produces "one poll returns the burst"
# semantics so the consumer reconciles once per wake.
item = self._queue.get_nowait()
else:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
item = self._queue.get(timeout=remaining)
out.append(item)
except queue.Empty:
# End-of-drain: the blocking get hit its deadline OR a
# get_nowait found the queue empty. Either way we return
# whatever was already collected.
pass
return out
def close(self) -> None:
if self._closed:
return
self._closed = True
if self._channels:
self._backend._notify_unregister(self._channels, self._queue)
class SQLiteBackend:
"""SQLite implementation of the StorageBackend protocol."""
@@ -155,6 +246,15 @@ class SQLiteBackend:
self._fts5_available = False
self._db_unavailable = False
self._db_unavailable_lock = threading.Lock()
# In-process notify fan-out: channel name -> list of stream queues.
# SQLite has no cross-process LISTEN/NOTIFY, so notifications are
# delivered synchronously to any open ``listen`` stream in the same
# process. Streams register on open + unregister on close; the
# synthetic-sweep timer below covers consumers that need a periodic
# wake regardless of producer activity (matching the PG-side
# discovery-loop cadence).
self._notify_lock = threading.Lock()
self._notify_subs: dict[str, list[queue.Queue[Any]]] = {}
if create_tables:
self._init_schema()
@@ -1877,6 +1977,33 @@ class SQLiteBackend:
).fetchall()
return [dict(r._mapping) for r in rows]
def find_watch_by_name(self, ws_id: str, name_or_prefix: str) -> dict[str, Any] | None:
if not name_or_prefix:
return None
like_pattern = _escape_like(name_or_prefix) + "%"
with self._conn() as conn:
row = conn.execute(
sa.select(watches)
.where(
(watches.c.ws_id == ws_id)
& (
(watches.c.name == name_or_prefix)
| watches.c.watch_id.like(like_pattern, escape=_LIKE_ESCAPE)
)
)
# Active rows win over inactive ones with the same name.
# _prepare_watch's duplicate-name guard filters active=1,
# so a model can recreate a name after the previous one
# auto-cancelled; a cancel-by-name request on the live
# row must not be shadowed by the older completed row.
.order_by(watches.c.active.desc(), watches.c.created.desc())
.limit(1)
).fetchone()
if row is None:
return None
return dict(row._mapping)
def list_watches_for_node(self, node_id: str) -> list[dict[str, Any]]:
with self._conn() as conn:
@@ -2009,6 +2136,75 @@ class SQLiteBackend:
conn.commit()
return result.rowcount > 0
# -- Cross-process notifications -------------------------------------------
def notify(self, channel: str, payload: str = "") -> None:
"""In-process broadcast — SQLite has no cross-process channel.
SQLite deployments are single-process by design (no shared backend
across nodes); the storage layer delivers to any ``listen`` stream
open in the same process. Cross-process consumers wouldn't be
served regardless the synthetic-sweep wake-up in :meth:`listen`
is the parity fallback so consumer code stays backend-agnostic.
"""
from turnstone.core.storage._notify import Notify
with self._notify_lock:
subs = list(self._notify_subs.get(channel, ()))
for q in subs:
with contextlib.suppress(Exception):
q.put(Notify(channel=channel, payload=payload, pid=0))
@contextlib.contextmanager
def listen(
self,
channels: Iterable[str],
*,
sweep_interval: float = _SQLITE_NOTIFY_SWEEP_INTERVAL,
) -> Iterator[NotifyStream]:
"""Subscribe to channels — synthetic-sweep + in-process fan-out.
The returned stream wakes every ``sweep_interval`` seconds with
one ``Notify(channel, payload="sweep", pid=0)`` per subscribed
channel; the default (:data:`_SQLITE_NOTIFY_SWEEP_INTERVAL`)
suits a dev backstop with a 60 s consumer-side timer. Callers
that need a tighter cadence (e.g. a future consumer without its
own polling timer) pass a smaller value here. In-process
:meth:`notify` calls deliver immediately on top of the sweep.
Either path produces a wake-up; consumers reconcile by re-reading
the relevant rows.
Channel names are de-duplicated so callers passing the same name
twice don't double-deliver each notify to a single stream.
"""
# de-dupe + preserve insertion order — passing the same channel
# twice would otherwise register the stream's queue against that
# channel twice and deliver each notify multiple times.
ch_list = list(dict.fromkeys(str(c) for c in channels if c))
stream = _SQLiteNotifyStream(self, ch_list, sweep_interval=sweep_interval)
try:
yield stream
finally:
stream.close()
def _notify_register(self, channels: list[str], q: queue.Queue[Any]) -> None:
"""Subscribe a stream's queue to in-process notifies on ``channels``."""
with self._notify_lock:
for ch in channels:
self._notify_subs.setdefault(ch, []).append(q)
def _notify_unregister(self, channels: list[str], q: queue.Queue[Any]) -> None:
"""Detach a stream's queue from in-process notifies on ``channels``."""
with self._notify_lock:
for ch in channels:
subs = self._notify_subs.get(ch)
if subs is None:
continue
with contextlib.suppress(ValueError):
subs.remove(q)
if not subs:
self._notify_subs.pop(ch, None)
# -- Node metadata ---------------------------------------------------------
def get_node_metadata(self, node_id: str) -> list[dict[str, Any]]:
@@ -3328,6 +3524,7 @@ class SQLiteBackend:
tier: str,
judge_model: str,
latency_ms: int,
user_decision: str = "pending",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
@@ -3348,11 +3545,67 @@ class SQLiteBackend:
"tier": tier,
"judge_model": judge_model,
"latency_ms": latency_ms,
"user_decision": user_decision,
"created": now,
},
)
conn.commit()
def upsert_intent_verdict(
self,
verdict_id: str,
ws_id: str,
call_id: str,
func_name: str,
func_args: str,
intent_summary: str,
risk_level: str,
confidence: float,
recommendation: str,
reasoning: str,
evidence: str,
tier: str,
judge_model: str,
latency_ms: int,
user_decision: str = "pending",
) -> None:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = sqlite_insert(intent_verdicts).values(
verdict_id=verdict_id,
ws_id=ws_id,
call_id=call_id,
func_name=func_name,
func_args=func_args,
intent_summary=intent_summary,
risk_level=risk_level,
confidence=confidence,
recommendation=recommendation,
reasoning=reasoning,
evidence=evidence,
tier=tier,
judge_model=judge_model,
latency_ms=latency_ms,
user_decision=user_decision,
created=now,
)
# On verdict_id conflict, update only the three fields that
# genuinely change between heuristic and llm_fallback. See the
# protocol docstring for the full exclusion rationale —
# ``user_decision`` exclusion in particular is load-bearing.
stmt = stmt.on_conflict_do_update(
index_elements=["verdict_id"],
set_={
"tier": tier,
"reasoning": reasoning,
"judge_model": judge_model,
},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def create_intent_verdicts_bulk(self, verdicts: list[dict[str, Any]]) -> None:
if not verdicts:
return
@@ -3373,6 +3626,7 @@ class SQLiteBackend:
"tier": v.get("tier", "heuristic"),
"judge_model": v.get("judge_model", ""),
"latency_ms": v.get("latency_ms", 0),
"user_decision": v.get("user_decision", "pending"),
"created": now,
}
for v in verdicts
@@ -4296,6 +4550,133 @@ class SQLiteBackend:
conn.commit()
return result.rowcount
# -- MCP pending-consent (Phase 9) ----------------------------------------
def upsert_mcp_pending_consent(
self,
user_id: str,
server_name: str,
error_code: str,
scopes_required: str | None,
last_ws_id: str | None,
last_tool_call_id: str | None,
now_iso: str,
) -> None:
from sqlalchemy.dialects import sqlite as sa_sqlite
stmt = sa_sqlite.insert(mcp_pending_consent).values(
user_id=user_id,
server_name=server_name,
error_code=error_code,
scopes_required=scopes_required,
last_ws_id=last_ws_id,
last_tool_call_id=last_tool_call_id,
first_seen_at=now_iso,
last_seen_at=now_iso,
occurrence_count=1,
)
stmt = stmt.on_conflict_do_update(
index_elements=["user_id", "server_name"],
set_={
"error_code": stmt.excluded.error_code,
"scopes_required": stmt.excluded.scopes_required,
"last_ws_id": stmt.excluded.last_ws_id,
"last_tool_call_id": stmt.excluded.last_tool_call_id,
"last_seen_at": stmt.excluded.last_seen_at,
"occurrence_count": mcp_pending_consent.c.occurrence_count + 1,
},
)
with self._conn() as conn:
conn.execute(stmt)
conn.commit()
def list_mcp_pending_consent_by_user(self, user_id: str) -> list[MCPPendingConsentRow]:
with self._conn() as conn:
rows = conn.execute(
sa.select(mcp_pending_consent)
.where(mcp_pending_consent.c.user_id == user_id)
.order_by(mcp_pending_consent.c.last_seen_at.desc())
).fetchall()
out: list[MCPPendingConsentRow] = []
for r in rows:
m = r._mapping
out.append(
MCPPendingConsentRow(
user_id=m["user_id"],
server_name=m["server_name"],
error_code=m["error_code"],
scopes_required=m["scopes_required"],
last_ws_id=m["last_ws_id"],
last_tool_call_id=m["last_tool_call_id"],
first_seen_at=m["first_seen_at"],
last_seen_at=m["last_seen_at"],
occurrence_count=m["occurrence_count"],
)
)
return out
def delete_mcp_pending_consent(self, user_id: str, server_name: str) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.delete(mcp_pending_consent).where(
(mcp_pending_consent.c.user_id == user_id)
& (mcp_pending_consent.c.server_name == server_name)
)
)
conn.commit()
return bool(result.rowcount)
def delete_all_mcp_pending_consent_by_user(self, user_id: str) -> int:
with self._conn() as conn:
result = conn.execute(
sa.delete(mcp_pending_consent).where(mcp_pending_consent.c.user_id == user_id)
)
conn.commit()
return int(result.rowcount or 0)
def count_mcp_consented_users_by_server(self, server_name: str) -> int:
now_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
result = conn.execute(
sa.select(sa.func.count(sa.distinct(mcp_user_tokens.c.user_id)))
.where(mcp_user_tokens.c.server_name == server_name)
.where(
sa.or_(
mcp_user_tokens.c.expires_at.is_(None),
mcp_user_tokens.c.expires_at > now_iso,
)
)
).scalar()
return int(result or 0)
def count_mcp_consented_users_grouped_by_server(self) -> dict[str, int]:
now_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn:
rows = conn.execute(
sa.select(
mcp_user_tokens.c.server_name,
sa.func.count(sa.distinct(mcp_user_tokens.c.user_id)),
)
.where(
sa.or_(
mcp_user_tokens.c.expires_at.is_(None),
mcp_user_tokens.c.expires_at > now_iso,
)
)
.group_by(mcp_user_tokens.c.server_name)
).fetchall()
return {row[0]: int(row[1] or 0) for row in rows}
def any_oauth_user_mcp_servers(self) -> bool:
with self._conn() as conn:
result = conn.execute(
sa.select(sa.literal(1))
.select_from(mcp_servers)
.where(mcp_servers.c.auth_type == "oauth_user")
.limit(1)
).scalar()
return result is not None
# -- Model definitions -----------------------------------------------------
def create_model_definition(
+31
View File
@@ -98,6 +98,37 @@ def sanitize_text(value: str | None) -> str | None:
return value
# ---------------------------------------------------------------------------
# SQL LIKE escaping
# ---------------------------------------------------------------------------
# The escape character paired with :func:`escape_like`. Callers MUST
# pass ``escape=LIKE_ESCAPE`` to SQLAlchemy's ``.like()`` — without
# that kwarg, ``.like()`` uses no escape character at all and the
# ``\%`` / ``\_`` sequences produced by :func:`escape_like` would be
# interpreted as a literal backslash followed by a wildcard. ``\``
# is the SQL standard escape character and works identically on SQLite
# and PostgreSQL when passed explicitly.
LIKE_ESCAPE = "\\"
def escape_like(value: str) -> str:
"""Escape ``%`` and ``_`` (and the escape character itself) so the
string can be safely embedded in a SQL ``LIKE`` pattern.
Pair with ``column.like(escape_like(prefix) + "%", escape=LIKE_ESCAPE)``
to do a true prefix match against caller-supplied input. Without
this, untrusted text containing ``%`` or ``_`` is interpreted as a
wildcard e.g. a model-supplied watch name of ``"%"`` would match
every row in the queried partition.
"""
return (
value.replace(LIKE_ESCAPE, LIKE_ESCAPE * 2)
.replace("%", LIKE_ESCAPE + "%")
.replace("_", LIKE_ESCAPE + "_")
)
# ---------------------------------------------------------------------------
# Row helper
# ---------------------------------------------------------------------------
@@ -0,0 +1,62 @@
"""Trigger ``pg_notify('services', ...)`` on service registry changes.
The console-side :class:`NotifyDispatcher` (`turnstone/console/notify_dispatcher.py`)
holds a dedicated LISTEN connection and fans channel events out to handlers.
This migration installs the producer side for the ``services`` channel
the cluster collector subscribes so new-node discovery is reactive instead
of polling every 60 s.
The trigger filters heartbeat-only UPDATEs in-trigger (same url + same
metadata, only ``last_heartbeat`` changed): ``register_service`` is an
UPSERT, so a node restart that changes url/metadata still fires; a plain
heartbeat tick stays quiet to avoid flooding the channel on every
30 s × N-nodes cluster tick. Channel payload is a small JSON object
service_type, service_id, op well below PG's 8 KiB NOTIFY limit; the
handler reconciles by re-reading ``services`` rather than relying on
the payload content.
SQLite is a no-op for this migration the SQLite backend's in-process
:meth:`notify` doesn't go through a trigger, and the synthetic-sweep
fallback in :meth:`listen` covers consumer parity.
What this trigger does NOT cover: crashed-node detection. A node that
dies without running its deregister handshake leaves a stale row that
ages out via the existing 120 s heartbeat-expiry filter. The 60 s
discovery loop in the collector keeps running as the backstop for
crash-shaped node loss.
Revision ID: 053
Revises: 052
Create Date: 2026-05-10
"""
import sqlalchemy as sa
from alembic import op
from turnstone.core.storage._schema import (
SERVICES_NOTIFY_TRIGGER_FN_NAME,
SERVICES_NOTIFY_TRIGGER_FN_SQL,
SERVICES_NOTIFY_TRIGGER_NAME,
SERVICES_NOTIFY_TRIGGER_SQL,
)
revision = "053"
down_revision = "052"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
return
op.execute(sa.text(SERVICES_NOTIFY_TRIGGER_FN_SQL))
op.execute(sa.text(SERVICES_NOTIFY_TRIGGER_SQL))
def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
return
op.execute(sa.text(f"DROP TRIGGER IF EXISTS {SERVICES_NOTIFY_TRIGGER_NAME} ON services"))
op.execute(sa.text(f"DROP FUNCTION IF EXISTS {SERVICES_NOTIFY_TRIGGER_FN_NAME}()"))
@@ -0,0 +1,50 @@
"""Add mcp_pending_consent table.
Stores per-(user, server) deferred-consent records emitted by the pool
dispatchers when a non-interactive run (scheduled / channel) hits
``mcp_consent_required`` or ``mcp_insufficient_scope``. Read on
dashboard load to render the "N MCP servers need consent" badge; cleared
by the OAuth callback handler when consent completes.
Composite PK ``(user_id, server_name)`` collapses repeat occurrences for
the same server into one row. No FKs (matches the rest of the
oauth_user schema in migration 049).
Revision ID: 054
Revises: 053
Create Date: 2026-05-11
"""
import sqlalchemy as sa
from alembic import op
revision = "054"
down_revision = "053"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"mcp_pending_consent",
sa.Column("user_id", sa.Text, nullable=False),
sa.Column("server_name", sa.Text, nullable=False),
sa.Column("error_code", sa.Text, nullable=False),
sa.Column("scopes_required", sa.Text, nullable=True),
sa.Column("last_ws_id", sa.Text, nullable=True),
sa.Column("last_tool_call_id", sa.Text, nullable=True),
sa.Column("first_seen_at", sa.Text, nullable=False),
sa.Column("last_seen_at", sa.Text, nullable=False),
sa.Column("occurrence_count", sa.Integer, nullable=False, server_default="1"),
sa.PrimaryKeyConstraint("user_id", "server_name"),
)
op.create_index(
"idx_mcp_pending_consent_user",
"mcp_pending_consent",
["user_id"],
)
def downgrade() -> None:
op.drop_index("idx_mcp_pending_consent_user", table_name="mcp_pending_consent")
op.drop_table("mcp_pending_consent")
@@ -0,0 +1,59 @@
"""Index mcp_user_tokens by (server_name, expires_at).
Phase 9 admin pill (``count_mcp_consented_users_*``) filters
``mcp_user_tokens`` by ``server_name`` and ``expires_at``. The table's
only existing index is the composite PK ``(user_id, server_name)``
``user_id`` is the leading column, so a filter on ``server_name`` alone
must full-scan the table. The bulk ``GROUP BY server_name`` variant in
the admin list handler benefits from the same index.
PostgreSQL uses ``CREATE INDEX CONCURRENTLY`` inside an
``autocommit_block`` so the build is non-blocking on a live system
``mcp_user_tokens`` is on the token-refresh hot path and an
ACCESS EXCLUSIVE lock during build would stall refresh writers on
installs with non-trivial row counts. SQLite has no concurrent build
concept and the table-level write lock already serializes, so a plain
``op.create_index`` is fine. Pattern mirrors migration 048
(``idx_workstreams_reaper``).
Revision ID: 055
Revises: 054
Create Date: 2026-05-11
"""
from alembic import op
revision = "055"
down_revision = "054"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
dialect = bind.dialect.name
if dialect == "postgresql":
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS "
"idx_mcp_user_tokens_server ON mcp_user_tokens "
"(server_name, expires_at)"
)
else:
op.create_index(
"idx_mcp_user_tokens_server",
"mcp_user_tokens",
["server_name", "expires_at"],
)
def downgrade() -> None:
bind = op.get_bind()
dialect = bind.dialect.name
if dialect == "postgresql":
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_mcp_user_tokens_server")
else:
op.drop_index("idx_mcp_user_tokens_server", table_name="mcp_user_tokens")
+92 -24
View File
@@ -289,6 +289,17 @@ class WatchRunner:
self._dispatch_fns: dict[str, Callable[[dict[str, Any], str], None]] = {}
self._dispatch_lock = threading.Lock()
# Watch ids whose terminal reminder has already been dispatched
# but whose row write has not yet been confirmed. Populated
# between ``_dispatch_result`` and ``update_watch`` in
# :meth:`_poll_watch`; on a subsequent tick the same row will
# still appear in ``list_due_watches`` (active=1, next_poll
# unchanged) — the guard at the top of ``_poll_watch`` retries
# the row write WITHOUT re-dispatching. Bounded by transient
# storage failure depth (~MAX_WATCHES_PER_WS × num_ws).
self._terminal_dispatched: set[str] = set()
self._terminal_dispatched_lock = threading.Lock()
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
@@ -314,14 +325,18 @@ class WatchRunner:
def set_dispatch_fn(self, ws_id: str, fn: Callable[[dict[str, Any], str], None]) -> None:
"""Register a per-workstream dispatch fn.
The fn signature is ``(reminder, watch_id)`` the runner passes
the originating ``watch_id`` so dispatch closures can capture
per-watch metadata (e.g. a ``valid_until`` predicate that
re-checks ``storage.is_watch_active(watch_id)`` before
delivering a stale entry). ``reminder`` is the structured dict
returned by :func:`build_watch_reminder` ``text`` carries the
formatted body, the remaining fields ride as queue-entry
metadata so the frontend can render a ``.msg.watch-result`` card.
The fn signature is ``(reminder, watch_id)``. ``reminder`` is
the structured dict returned by :func:`build_watch_reminder`
``text`` carries the formatted body, the remaining fields ride
as queue-entry metadata so the frontend can render a
``.msg.watch-result`` card. ``watch_id`` is passed for
closures that need per-watch metadata in their queue plumbing
(e.g. correlating a fire back to the originating row in logs);
do NOT use it to gate delivery against
``storage.is_watch_active(watch_id)`` see
:meth:`ChatSession.set_watch_runner` for why that pattern
races :meth:`_poll_watch`'s commit of ``active=False`` and
drops fires the model was meant to see.
"""
with self._dispatch_lock:
self._dispatch_fns[ws_id] = fn
@@ -339,6 +354,22 @@ class WatchRunner:
with self._dispatch_lock:
return self._dispatch_fns.get(ws_id)
def forget_terminal_dispatched(self, watch_id: str) -> None:
"""Discard ``watch_id`` from the pending-terminal-dispatched
set if present. Called by paths that take a watch out of
:meth:`StorageBackend.list_due_watches` view independent of
the runner's own poll (most importantly the user-cancel path
in :meth:`ChatSession._exec_watch`). Without this, a
``_poll_watch`` whose row write failed AFTER dispatch would
leak ``watch_id`` in ``_terminal_dispatched`` indefinitely
the user-cancel writes ``next_poll=''`` which excludes the
row from ``list_due_watches``, so the retry-deactivate branch
at the top of :meth:`_poll_watch` never fires to clear the
entry.
"""
with self._terminal_dispatched_lock:
self._terminal_dispatched.discard(watch_id)
# -- Main loop -----------------------------------------------------------
def _run(self) -> None:
@@ -383,6 +414,21 @@ class WatchRunner:
prev_output = watch_row.get("last_output")
created = watch_row.get("created", "")
# Re-poll of a row whose terminal reminder already shipped but
# whose ``active=False`` write didn't land — retry just the row
# write so the row stops appearing in ``list_due_watches``; do
# NOT re-dispatch the reminder, which the model already saw.
with self._terminal_dispatched_lock:
already_dispatched = watch_id in self._terminal_dispatched
if already_dispatched:
try:
self._storage.update_watch(watch_id, active=False, next_poll="")
with self._terminal_dispatched_lock:
self._terminal_dispatched.discard(watch_id)
except Exception:
log.exception("watch_runner.retry_deactivate_failed", extra={"watch_id": watch_id})
return
# Safety check
blocked = is_command_blocked(command)
if blocked:
@@ -416,22 +462,17 @@ class WatchRunner:
now = datetime.now(UTC)
now_str = now.strftime("%Y-%m-%dT%H:%M:%S")
# Update DB
update_fields: dict[str, Any] = {
"poll_count": poll_count,
"last_output": output,
"last_exit_code": exit_code,
"last_poll": now_str,
}
if is_final:
update_fields["active"] = False
update_fields["next_poll"] = ""
else:
next_poll = now + timedelta(seconds=watch_row["interval_secs"])
update_fields["next_poll"] = next_poll.strftime("%Y-%m-%dT%H:%M:%S")
self._storage.update_watch(watch_id, **update_fields)
# Dispatch result if condition fired or final
# Dispatch before committing the row update. Belt-and-braces
# given the rest of the fix (closure no longer wires a
# ``valid_until`` predicate, cancel-by-name uses
# :meth:`find_watch_by_name` which ignores the ``active``
# filter): either order would deliver the reminder today, but
# this ordering preserves the invariant against re-wiring an
# ``is_watch_active`` predicate or adding a new
# ``active``-filtered read on this hot path. Combined with the
# ``_terminal_dispatched`` guard above it also bounds the
# duplicate-fire blast radius if the row write fails after the
# reminder shipped.
if fired or is_final:
# Compute elapsed from created time
elapsed_secs = 0.0
@@ -454,6 +495,33 @@ class WatchRunner:
reason=reason,
)
self._dispatch_result(ws_id, reminder, watch_id)
if is_final:
# Mark BEFORE the row write so a raise below routes the
# next tick into the retry-deactivate branch instead of
# re-firing the reminder.
with self._terminal_dispatched_lock:
self._terminal_dispatched.add(watch_id)
# Update DB
update_fields: dict[str, Any] = {
"poll_count": poll_count,
"last_output": output,
"last_exit_code": exit_code,
"last_poll": now_str,
}
if is_final:
update_fields["active"] = False
update_fields["next_poll"] = ""
else:
next_poll = now + timedelta(seconds=watch_row["interval_secs"])
update_fields["next_poll"] = next_poll.strftime("%Y-%m-%dT%H:%M:%S")
self._storage.update_watch(watch_id, **update_fields)
if is_final:
# Row write committed; the retry-deactivate branch will
# never be reached for this watch_id.
with self._terminal_dispatched_lock:
self._terminal_dispatched.discard(watch_id)
log.debug(
"watch_runner.polled",
+14
View File
@@ -37,6 +37,19 @@ class ClientType(enum.StrEnum):
WEB = "web"
CLI = "cli"
CHAT = "chat"
SCHEDULED = "scheduled"
# Subset of ``ClientType`` values where the user is present to complete
# an in-flight OAuth consent flow (browser redirect + return). CHAT and
# SCHEDULED users cannot drive a browser redirect from inside their
# delivery surface, so consent-required errors must be persisted to
# ``mcp_pending_consent`` for later surfacing rather than relying on the
# in-flight SSE rendering path. Used by ``ChatSession`` to set
# ``_is_interactive_for_consent`` at construction time.
INTERACTIVE_CONSENT_CLIENT_TYPES: frozenset[ClientType] = frozenset(
{ClientType.WEB, ClientType.CLI}
)
@dataclasses.dataclass
@@ -56,6 +69,7 @@ _ENV_MAP: dict[ClientType, str] = {
ClientType.WEB: "env/web.md",
ClientType.CLI: "env/cli.md",
ClientType.CHAT: "env/chat.md",
ClientType.SCHEDULED: "env/scheduled.md",
}
+20
View File
@@ -0,0 +1,20 @@
## Output Environment
Your response is generated by a scheduled or autonomous run — no human is watching the output as it streams. The result is delivered to the user later (Discord notification, dashboard badge, or persisted workstream history) where they will see it as a static block of markdown.
**Implications:**
- The user is not online to answer mid-task clarifying questions. Make the reasonable judgment and continue; mention the assumption you made in the final output so the user can correct course on the next run if needed.
- Tool calls that require interactive user consent (e.g., MCP servers gated on OAuth user authorization that the user has not yet completed) will return a deferred-consent error rather than block the run. Surface the deferred work in your final summary so the user knows what was skipped.
- Optimize for a clear, scannable final summary over conversational back-and-forth — the user reads the whole transcript at once, not turn-by-turn.
**Available rendering:**
- Standard GitHub-flavored markdown is supported in the dashboard surface. Discord delivery uses the same constraints as `chat.md` (no tables, no headings beyond bold text, no Mermaid/KaTeX).
- Default to chat-portable formatting (bold/italic/inline-code/bullets/code-blocks) unless you know the destination is the web dashboard.
**Formatting principles:**
- Lead with the outcome in one line: what was accomplished, what was skipped, and why.
- For multi-step work, end with a concise checklist of what ran and what remains.
- Cite specific identifiers (workstream IDs, tool names, MCP server names) so the user can resume the work without re-reading the trace.
+32
View File
@@ -2822,6 +2822,27 @@ async def mcp_oauth_revoke_connection(request: Request) -> Response:
return await handle_mcp_oauth_revoke_connection(request)
async def mcp_oauth_list_pending(request: Request) -> Response:
"""GET /v1/api/mcp/oauth/pending — list deferred-consent records (Phase 9)."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_list_pending
return await handle_mcp_oauth_list_pending(request)
async def mcp_oauth_clear_pending(request: Request) -> Response:
"""DELETE /v1/api/mcp/oauth/pending/{server_name} — dismiss a deferred-consent record."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_clear_pending
return await handle_mcp_oauth_clear_pending(request)
async def mcp_oauth_clear_all_pending(request: Request) -> Response:
"""DELETE /v1/api/mcp/oauth/pending — bulk-dismiss deferred-consent records."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_clear_all_pending
return await handle_mcp_oauth_clear_all_pending(request)
def list_interface_settings(request: Request) -> JSONResponse:
"""GET /v1/api/admin/settings — return interface settings from ConfigStore.
@@ -4064,6 +4085,17 @@ def create_app(
mcp_oauth_revoke_connection,
methods=["DELETE"],
),
Route("/api/mcp/oauth/pending", mcp_oauth_list_pending),
Route(
"/api/mcp/oauth/pending",
mcp_oauth_clear_all_pending,
methods=["DELETE"],
),
Route(
"/api/mcp/oauth/pending/{server_name}",
mcp_oauth_clear_pending,
methods=["DELETE"],
),
Route("/api/admin/settings", list_interface_settings),
Route(
"/api/admin/settings/{key:path}",
+72 -72
View File
@@ -8,14 +8,14 @@
3. If auth_enabled + has_users show login (username:password)
4. Legacy: token-based login still supported via toggle */
var _AUTH_TITLE = window.TURNSTONE_AUTH_TITLE || "turnstone";
var _loginTrapHandler = null;
var _loginBusy = false;
var _authMode = "login"; // "login", "setup", "token"
var _authUpgradeReload = false;
const _AUTH_TITLE = window.TURNSTONE_AUTH_TITLE || "turnstone";
let _loginTrapHandler = null;
let _loginBusy = false;
let _authMode = "login"; // "login", "setup", "token"
let _authUpgradeReload = false;
// Cross-tab auth sync — when one tab logs in/out, others follow.
var _authChannel =
const _authChannel =
typeof BroadcastChannel !== "undefined"
? new BroadcastChannel("turnstone_auth")
: null;
@@ -37,12 +37,12 @@ if (_authChannel) {
}
async function authFetch(url, opts) {
var maxRetries = 2;
for (var attempt = 0; attempt <= maxRetries; attempt++) {
var r = await fetch(url, opts);
const maxRetries = 2;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const r = await fetch(url, opts);
if (r.status === 401) {
try {
var body = await r.clone().json();
const body = await r.clone().json();
if (body && body.code === "version_mismatch") {
_authUpgradeReload = true;
showLogin("upgrade");
@@ -62,7 +62,7 @@ async function authFetch(url, opts) {
throw new Error("auth");
}
if (r.status === 429 && attempt < maxRetries) {
var retryAfter = parseInt(r.headers.get("Retry-After") || "1", 10);
const retryAfter = parseInt(r.headers.get("Retry-After") || "1", 10);
showToast("Rate limited \u2014 retrying in " + retryAfter + "s");
await new Promise(function (resolve) {
setTimeout(resolve, retryAfter * 1000);
@@ -70,7 +70,7 @@ async function authFetch(url, opts) {
continue;
}
// Successful auth — ensure logout button and SSE connection
var _lb = document.getElementById("logout-btn");
const _lb = document.getElementById("logout-btn");
if (_lb) _lb.style.display = "";
if (typeof _ensureSSE === "function") _ensureSSE();
return r;
@@ -86,22 +86,22 @@ async function authFetch(url, opts) {
// hammering the server for every authFetch. The reactive _tryRefresh()
// path above covers cases where the timer didn't fire (tab restored from
// disk cache after expiry, system clock jump, etc).
var _REFRESH_AT_FRACTION = 0.9;
const _REFRESH_AT_FRACTION = 0.9;
// Floor so we don't spin on tiny lifetimes; ceil so very long-lived
// cookies still refresh once a day for permission re-resolution.
var _REFRESH_MIN_DELAY_MS = 30 * 1000;
var _REFRESH_MAX_DELAY_MS = 24 * 60 * 60 * 1000;
var _refreshTimer = null;
var _refreshInFlight = null;
const _REFRESH_MIN_DELAY_MS = 30 * 1000;
const _REFRESH_MAX_DELAY_MS = 24 * 60 * 60 * 1000;
let _refreshTimer = null;
let _refreshInFlight = null;
// Logout race guard: a refresh (or whoami) in flight when the user
// clicks Logout can land AFTER /logout and re-populate state, silently
// undoing the logout. _loggedOut is set synchronously in logout() and
// every fetch's .then bails on its post-fetch effects when it sees the
// flag. _refreshAbort / _whoamiAbort are the AbortControllers for any
// in-flight /refresh and /whoami respectively.
var _loggedOut = false;
var _refreshAbort = null;
var _whoamiAbort = null;
let _loggedOut = false;
let _refreshAbort = null;
let _whoamiAbort = null;
// Permissions-ready: one-shot promise resolved after the initial whoami
// completes (success OR failure). Lets permission-gated UI await the
@@ -109,13 +109,13 @@ var _whoamiAbort = null;
// guessing a setTimeout duration. Subsequent logins/logouts refresh
// permissions through the existing onLoginSuccess / onLogout hooks, so
// one-shot is sufficient for the page-load gate problem.
var _permissionsReadyResolve = null;
var _permissionsReady = new Promise(function (resolve) {
let _permissionsReadyResolve = null;
const _permissionsReady = new Promise(function (resolve) {
_permissionsReadyResolve = resolve;
});
function _markPermissionsReady() {
if (_permissionsReadyResolve) {
var r = _permissionsReadyResolve;
const r = _permissionsReadyResolve;
_permissionsReadyResolve = null;
r();
}
@@ -140,14 +140,14 @@ async function _tryRefresh() {
typeof AbortController !== "undefined" ? new AbortController() : null;
_refreshInFlight = (async function () {
try {
var r = await fetch("/v1/api/auth/refresh", {
const r = await fetch("/v1/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
signal: _refreshAbort ? _refreshAbort.signal : undefined,
});
if (!r.ok) return false;
var data = null;
let data = null;
try {
data = await r.json();
} catch (_e) {
@@ -197,11 +197,11 @@ function _scheduleRefreshAt(epochSeconds) {
_refreshTimer = null;
}
if (typeof epochSeconds !== "number" || !isFinite(epochSeconds)) return;
var nowMs = Date.now();
var expMs = epochSeconds * 1000;
var remaining = expMs - nowMs;
const nowMs = Date.now();
const expMs = epochSeconds * 1000;
const remaining = expMs - nowMs;
if (remaining <= 0) return; // already expired; reactive path handles it
var delay = Math.floor(remaining * _REFRESH_AT_FRACTION);
let delay = Math.floor(remaining * _REFRESH_AT_FRACTION);
if (delay < _REFRESH_MIN_DELAY_MS) delay = _REFRESH_MIN_DELAY_MS;
if (delay > _REFRESH_MAX_DELAY_MS) delay = _REFRESH_MAX_DELAY_MS;
_refreshTimer = setTimeout(function () {
@@ -239,7 +239,7 @@ function _scheduleRefreshFromWhoami() {
// prior in-flight whoami before starting a new one AND guard the
// post-fetch effects with `_whoamiAbort === ctrl` so a late arrival
// from a superseded call is fully neutralised.
var prior = _whoamiAbort;
const prior = _whoamiAbort;
if (prior) {
try {
prior.abort();
@@ -247,7 +247,7 @@ function _scheduleRefreshFromWhoami() {
/* AbortController not available; the equality check below covers it */
}
}
var ctrl =
const ctrl =
typeof AbortController !== "undefined" ? new AbortController() : null;
_whoamiAbort = ctrl;
fetch("/v1/api/auth/whoami", {
@@ -293,19 +293,19 @@ function _cancelRefreshTimer() {
}
function initLogin() {
var overlay = document.createElement("div");
const overlay = document.createElement("div");
overlay.id = "login-overlay";
overlay.style.display = "none";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "login-title");
overlay.innerHTML = _buildLoginHTML();
setSafeHtml(overlay, _buildLoginHTML());
document.body.appendChild(overlay);
_bindLoginEvents();
// OIDC callback: detect success or error from URL params
var _oidcParams = new URLSearchParams(window.location.search);
var _oidcError = _oidcParams.get("oidc_error");
const _oidcParams = new URLSearchParams(window.location.search);
const _oidcError = _oidcParams.get("oidc_error");
if (_oidcError) {
history.replaceState({}, "", window.location.pathname);
// showLogin's status-fetch resolves _switchMode (which clears errors)
@@ -382,8 +382,8 @@ function _bindLoginEvents() {
});
// Escape key clears errors
var inputs = document.querySelectorAll("#login-box input");
for (var i = 0; i < inputs.length; i++) {
const inputs = document.querySelectorAll("#login-box input");
for (let i = 0; i < inputs.length; i++) {
inputs[i].addEventListener("keydown", function (e) {
if (e.key === "Escape") _clearError();
});
@@ -401,13 +401,13 @@ function _bindLoginEvents() {
function _switchMode(mode) {
_authMode = mode;
var setupFields = document.getElementById("setup-fields");
var loginFields = document.getElementById("login-fields");
var tokenFields = document.getElementById("token-fields");
var toggleDiv = document.getElementById("login-toggle");
var toggleBtn = document.getElementById("toggle-token");
var subtitle = document.getElementById("login-subtitle");
var btn = document.getElementById("login-submit");
const setupFields = document.getElementById("setup-fields");
const loginFields = document.getElementById("login-fields");
const tokenFields = document.getElementById("token-fields");
const toggleDiv = document.getElementById("login-toggle");
const toggleBtn = document.getElementById("toggle-token");
const subtitle = document.getElementById("login-subtitle");
const btn = document.getElementById("login-submit");
setupFields.style.display = "none";
loginFields.style.display = "none";
@@ -444,9 +444,9 @@ function _switchMode(mode) {
}
function _updateOIDCUI(data) {
var section = document.getElementById("oidc-section");
var btn = document.getElementById("oidc-btn");
var divider = document.getElementById("oidc-divider");
const section = document.getElementById("oidc-section");
const btn = document.getElementById("oidc-btn");
const divider = document.getElementById("oidc-divider");
if (!section) return;
if (!data.oidc_enabled || _authMode === "setup") {
@@ -469,7 +469,7 @@ function _updateOIDCUI(data) {
}
function _clearError() {
var errEl = document.getElementById("login-error");
const errEl = document.getElementById("login-error");
if (errEl && errEl.style.display !== "none") {
errEl.style.display = "none";
errEl.textContent = "";
@@ -477,7 +477,7 @@ function _clearError() {
}
function _showError(msg) {
var errEl = document.getElementById("login-error");
const errEl = document.getElementById("login-error");
if (errEl) {
errEl.textContent = msg;
errEl.style.display = "block";
@@ -485,17 +485,17 @@ function _showError(msg) {
}
function showLogin(reason, oidcError) {
var overlay = document.getElementById("login-overlay");
const overlay = document.getElementById("login-overlay");
if (!overlay) return;
overlay.style.display = "flex";
document.body.style.overflow = "hidden";
var logoutBtn = document.getElementById("logout-btn");
const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) logoutBtn.style.display = "none";
_clearError();
// Check auth status to determine mode
var _loginReason = reason;
var _oidcError = oidcError;
const _loginReason = reason;
const _oidcError = oidcError;
fetch("/v1/api/auth/status")
.then(function (r) {
return r.json();
@@ -506,7 +506,7 @@ function showLogin(reason, oidcError) {
} else {
_switchMode("login");
if (_loginReason === "upgrade") {
var subtitle = document.getElementById("login-subtitle");
const subtitle = document.getElementById("login-subtitle");
if (subtitle)
subtitle.textContent =
"The server was updated \u2014 please sign in again";
@@ -526,18 +526,18 @@ function showLogin(reason, oidcError) {
document.removeEventListener("keydown", _loginTrapHandler);
_loginTrapHandler = function (e) {
if (e.key === "Tab") {
var box = document.getElementById("login-box");
var focusable = box.querySelectorAll(
const box = document.getElementById("login-box");
const focusable = box.querySelectorAll(
'input:not([style*="display: none"]):not([style*="display:none"]), button:not([style*="display: none"]):not([style*="display:none"])',
);
// Filter to visible elements
var visible = [];
for (var i = 0; i < focusable.length; i++) {
const visible = [];
for (let i = 0; i < focusable.length; i++) {
if (focusable[i].offsetParent !== null) visible.push(focusable[i]);
}
if (visible.length === 0) return;
var first = visible[0];
var last = visible[visible.length - 1];
const first = visible[0];
const last = visible[visible.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
@@ -555,7 +555,7 @@ function showLogin(reason, oidcError) {
}
function hideLogin() {
var overlay = document.getElementById("login-overlay");
const overlay = document.getElementById("login-overlay");
if (overlay) overlay.style.display = "none";
document.body.style.overflow = "";
if (_loginTrapHandler) {
@@ -572,8 +572,8 @@ function _handleSubmit() {
}
function _submitLogin() {
var username = (document.getElementById("login-username").value || "").trim();
var password = document.getElementById("login-password").value || "";
const username = (document.getElementById("login-username").value || "").trim();
const password = document.getElementById("login-password").value || "";
if (!username) {
_showError("Username is required");
@@ -611,7 +611,7 @@ function _submitLogin() {
}
function _submitToken() {
var token = (document.getElementById("login-token").value || "").trim();
const token = (document.getElementById("login-token").value || "").trim();
if (!token) {
_showError("Token is required");
return;
@@ -644,12 +644,12 @@ function _submitToken() {
}
function _submitSetup() {
var username = (document.getElementById("setup-username").value || "").trim();
var displayName = (
const username = (document.getElementById("setup-username").value || "").trim();
const displayName = (
document.getElementById("setup-displayname").value || ""
).trim();
var password = document.getElementById("setup-password").value || "";
var confirm = document.getElementById("setup-confirm").value || "";
const password = document.getElementById("setup-password").value || "";
const confirm = document.getElementById("setup-confirm").value || "";
if (!username) {
_showError("Username is required");
@@ -713,15 +713,15 @@ function _storePermissions(data) {
function _setBusy(busy, label) {
_loginBusy = busy;
var btn = document.getElementById("login-submit");
var inputs = document.querySelectorAll("#login-box input");
const btn = document.getElementById("login-submit");
const inputs = document.querySelectorAll("#login-box input");
btn.disabled = busy;
if (busy) {
btn.textContent = label || "Signing in\u2026";
} else {
btn.textContent = _authMode === "setup" ? "Create account" : "Sign in";
}
for (var i = 0; i < inputs.length; i++) {
for (let i = 0; i < inputs.length; i++) {
inputs[i].disabled = busy;
}
}
@@ -738,7 +738,7 @@ function _onSuccess() {
// refreshes work again.
_loggedOut = false;
hideLogin();
var logoutBtn = document.getElementById("logout-btn");
const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) logoutBtn.style.display = "";
if (_authChannel) _authChannel.postMessage("login");
if (typeof window.onLoginSuccess === "function") window.onLoginSuccess();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More