Compare commits

..

410 Commits

Author SHA1 Message Date
Patrick Buckley f138784ba3 chore: bump version to 1.8.0a6 2026-08-06 15:48:17 -07:00
Patrick Buckley 0cdb679892 Follow-ups on the #832 fold: supersession predicate, wire-prep error hygiene, reasoning-parser tile (#986)
* refactor(session): ask the shared supersession predicate at the older sites

``_check_cancelled`` and ``_compaction_event`` predate
``_generation_superseded`` and each carried its own inline copy of the
formula, so the drift the helper exists to prevent had two live places
to start from.

Both are behaviour-identical today.  What the pin protects is the
generation-0 convention: a bare ``!=`` reads a direct seam caller as an
orphan, which would raise a cancel on a live turn and stamp a live
compaction superseded — suppressing the end notice, so an operator
watching a real compaction fail would be told nothing at all.

* fix(session): render a wire-prep fault's cause class, never its message

Every other branch of the fatal formatter tails the backend's own
diagnostic text, which is what the operator needs.  This branch is
different in kind: ``prepare_wire`` is our lowering over the session's
stored history, so its exception message can quote that history — and
the formatted string is both shown to the operator and persisted to
``last_error``, which a coordinating agent reads.  ``redact_credentials``
is a best-effort regex by its own docstring, so it is no floor for
arbitrary conversation text.

The cause's class still identifies the fault, the guidance is unchanged,
and the debug traceback logged in the same function localizes the raise
site.

* feat(console): surface the server-side reasoning parser capability

The inline think-tag scan is a fallback for inference servers with no
reasoning parser, and for misconfigured ones.  An operator running vLLM
or llama.cpp with a parser configured had no way to say so from the
model shelf — ``server_parses_reasoning`` was reachable only by hand
editing the raw capabilities JSON, and it defaults to off, so the scan
stays on and both channels run at once.

The tile test is a general invariant rather than a single-key pin: every
tile key must render a checkbox, carry a default, and — where the key is
a ``ModelCapabilities`` field — agree with the dataclass.  The matrix is
a hand-maintained mirror, so it drifts silently otherwise.

* fix(model_turn): a wire-prep wrapper carries the cause's class, not its text

Withholding the message in the fatal formatter was not enough.  The
wrapper was built as ``WirePreparationError(str(prep_err))``, so
``str(exc)`` IS the cause's message — and the interactive retry arm
renders exactly that into the dashboard SSE, one line after the formatter
emitted the redacted version.  ``sanitize_error_text`` is no floor there:
it returns arbitrary stored-history text unchanged.

Fixing the exception rather than the one consumer closes every caller
that stringifies it, now and later.  The message still rides
``__cause__`` for tracebacks and debug logs.

* fix(console): coerce lifted capability values the way the backend does

The tile lift used bare ``!!``, but the capabilities dict is hand-edited
JSON: a stored string "false" is truthy to JS while
``apply_capability_overrides`` reads it as False.  Opening such a row
rendered the tile CHECKED and saving persisted boolean true — inverting
the capability without the operator touching it.  For
``server_parses_reasoning`` that silently disables the inline tag scan,
the exact typo model_turn's comment already warns about, and this key had
just been lifted into the matrix.

``_capBool`` mirrors the backend's spelling table; a value the backend
would not coerce stays in the raw JSON rather than being rewritten, which
is the policy the modal already applies to thinking_mode.  Cases are
generated from the Python table and executed under node, so a spelling
added on one side fails here.

Also tightens two pins the tile test left open: the checkbox must render
inside the container the JS actually queries, and a tile key that is not
a capability field is exempted by NAME rather than by a blanket hasattr,
which was swallowing the consistent-rename case.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-06 15:14:52 -07:00
Patrick Buckley f15e53dd36 test: drop a no-op conditional and splat the pre-fold seam call
Static analysis on the pull request caught two leftovers from the
mechanical ports. An `if True:` wrapper survived the conversion of a
patch block into the armed-provider fake, adding a nesting level that
manages nothing — the same shape as the `nullcontext` leftover removed
earlier, and the file now has neither.

The parity runner's pre-fold branch calls the seam with two arguments,
which is correct only on a tree whose signature still takes the wire
list; against the signature this tree has it reads as an arity error to
a checker and to a reader. Splatting a named tuple states that the
two-argument form belongs to the other world.
2026-08-06 01:04:32 -07:00
Patrick Buckley df8a374c3d test(session): cover the orphan guards in the streaming arms
Branch coverage showed the supersession guard in the Exception arm never
executed and the one in the Ctrl-C arm only ever took its live side. The
reason is structural rather than neglect: the ladder converts
supersession before these arms can see it, since _model_turn_with_retry
re-checks the generation ahead of classifying a death, so on every
deterministic path an orphan's failure arrives as GenerationCancelled.
The guards exist for the sub-statement race where a force-cancel lands
after that check — the same accepted window the cancel ref documents —
which no scripted stream can reach.

These drive the seam directly to simulate it: the attempt arms, a newer
generation claims the session, then the failure surfaces. They pin what
the guards protect — an orphaned thread emits nothing, because the
successor generation is already streaming into the same UI — plus the
live counterpart, where a Ctrl-C still finalizes the display. Deleting
either guard, or inverting the Ctrl-C one, fails them.
2026-08-06 01:04:32 -07:00
Patrick Buckley 50e080c18b fix(session): one supersession predicate, asked the same way everywhere
Scoping the arm-duty gate left four sibling gates in the same streaming
turn still comparing generations with a bare !=, so one function could
reach opposite verdicts for one generation shape: a Stop finalized the
display and stashed the partial where a Ctrl-C on the identical shape
did neither. _generation_superseded() is now the single predicate and
every site asks it — the cancel ref, the streaming consumer, the
dead-partial promotion, the Ctrl-C arm, and the orphan arm.

Each caller still performs its own read. That is the point rather than
an accident: the consumer's read is a genuine second look after the
ref's, and a consumer that delegated to the ref would inherit its stale
answer and run the arm duties for an orphan — nulling the successor's
usage slots and recording health for an abandoned lane.

Tests: TestSupersessionVerdictAgreement pins that the arms agree, in
both directions. Its orphan case pins the stronger invariant it turned
out to hold — a superseded generation never reaches an arm at all,
because the ref reads superseded and model_turn refuses to dispatch. The
last two hand-rolled dataclasses in the suite are replaced by the real
ToolCallDelta, and the prepare_wire docstring paragraph is re-flowed.
2026-08-06 01:04:32 -07:00
Patrick Buckley 4dd92d150b fix(session): scope the arm-duty gate the way the rest of the file scopes generations
The consumer's arm hook and cancel-partial recorder compared generations
with a bare !=, while the ref that fires them treats generation 0 as
UNSCOPED — so for a direct seam caller the ref armed and fired the hook
and the hook refused to act. On a session whose generation had ever been
claimed, that left the previous turn's usage in place as this turn's
estimate and dropped the serving lane's health success. Both now ask the
consumer's own _superseded(), which mirrors the ref's predicate, so the
two halves of one decision cannot disagree.

The which-errors-speak-for-the-backend policy gets one spelling
(_speaks_for_backend over _NON_BACKEND_ERRORS) instead of a matching
isinstance in each walk arm, and the length arm stops calling
finalize_provider_blocks over an empty list only to discard the result.

Tests: the fourteen hand-rolled FakeChunk dataclasses in the cancel suite
are replaced by the real StreamChunk its sibling suites already use, so
the fakes cannot drift from the shape production emits.
2026-08-06 01:04:32 -07:00
Patrick Buckley 5de54147e1 fix(832): a prep fault walks the fallbacks it can no longer speak for
Making prepare_wire lane-variant invalidated the premise behind the
walk-abort on WirePreparationError: with the fold posture following each
lane's capabilities, a preparation fault on one lane no longer implies
every lane fails, so aborting the walk skipped healthy fallbacks and the
dedicated fatal message was wrong on both of its claims. Preparation
faults now keep their no-health rule on every lane but continue the
walk — the primary's fault enters it and a fallback's fault yields to
the next alias — and the fatal message drops the no-fallback claim.

Riding cleanup: the self-surfacing exception pair gets one spelling for
the re-issue mask (_SELF_SURFACING_ERRORS; the walk arms stay per-class
because auth aborts where prep continues); the tag-scan gate gains a
capabilities-shaped form (caps_scan_inline_reasoning) that the lane form
delegates to and the title peel now uses, retiring the third spelling;
the three streaming provider fakes build on one provider_shell; a
comment in session_ui_base names the module function that replaced the
deleted session delegate; close_run spells its carry cut as
removesuffix; and the prepare_wire docstring paragraph is re-flowed.

The walk-continues and per-lane no-health pins are mutation-probed.
2026-08-06 01:04:32 -07:00
Patrick Buckley c906776efd fix(832): the serving lane's capabilities reach the wire fold
The per-attempt prepare_wire closure folded mid-conversation system
turns with the PRIMARY binding's capabilities on every lane, so a
fallback whose chat template rejects non-leading system roles failed on
the self-inflicted wire shape and burned its own health record — the
wrong-dialect class the walk's binding snapshot guards against
elsewhere. model_turn now passes the serving lane to prepare_wire, and
the session's closure folds with that lane's capabilities; callers
without a lane in hand (the token-table re-fold) keep the primary
default. Pre-fold prepared once with primary caps for every lane, so
this is a named improvement, not a parity break.

The arm-duties hook rode the same unguarded two-statement supersession
window the _CancelRef docstring accepts only for the stream register: a
force-cancel claiming a new generation between the superseded read and
the hook let an orphan's late registration null the successor's usage
slots and record spurious creation health. on_stream_armed now
generation-gates itself, shrinking the accepted window's harm back to
the register-only class.

Test hygiene: the two overflow-compact tests are one parametrized body;
arm_session mints a fresh ArmedHandle per create (provider.handles,
_armed_handle = latest) matching the one-handle-per-create rule of real
adapters. The duplicate sanitize pass stands as
designed (accepted for wire parity); its perf note rides #979.

All three product fixes are mutation-probed.
2026-08-06 01:04:32 -07:00
Patrick Buckley 90e55f92ca docs(832): shorten the branch's comments to their constraints
Comment-only sweep over the diff's prose: origin archaeology, next-line
narration, and review-thread talk go; each surviving comment states the
constraint the code cannot show, re-wrapped to the file's width. The
ruled-behavior restatements in the parity transforms and the contract
docstrings (eager append, cancel-predicate pairing, carry ownership,
the plant call's carve-outs) keep every named invariant.
2026-08-06 01:04:32 -07:00
Patrick Buckley 06ec1a8629 fix(832): the boundary carry belongs to the run owner
The mandated cross-lane interleave angle found the two residual holes in
the reasoning-boundary close: the close was gated on not-in_think, so an
open inline think block at the boundary never closed and the later state
flip relabeled held chain-of-thought as displayed ANSWER text; and the
carry parked in the splitter's own pending was re-read under whatever
state later flushes hit, relabeling a content-state tail as reasoning.
close_run() now closes unconditionally (as the drain does) and RETURNS
the partial-tag tail; the consumer owns the carry in a state-immune slot
mirroring the drain's separate variable — re-fed when content resumes so
a split tag still reassembles, flushed as content at tool, finish, and
cancel boundaries, and included in the partial-content rule.

The trailing citations footer is now HELD and folded once at stream end
over the full answer — structurally the drain's post-loop fold — instead
of folding at arrival, which diverged from the commit whenever a lax
gateway emitted content after finish.

Two non-mirror fixes: the fallback-failure UI line carries the exception
class only (its text can embed a credential-bearing base_url; detail
goes to the server log, same rule as the re-issue log arm), and a
never-armed Stop (creation window, no prior death, zero tokens) writes
NO assistant row again — restoring pre-fold semantics; a marker-only row
would replay to the model as context on every later turn. Armed
zero-token Stops still record their marker.

Hygiene riding along: the parity runner zeroes the ladder backoff (the
exhaust scenario was sleeping 3.2s of real backoff per suite run, with
the retry-notice transform strings updated in step); test_session's
porting docstring points at the helper's real module; test_cancel and
test_session wrap the shared session factory instead of re-implementing
its defaults; arm_session's armed handle is an ArmedHandle with real
closed state instead of a MagicMock that satisfies any assertion; and
send() derives the tool-call list once for both the persisted mirror
and the executed set.

All fixes are mutation-probed: re-gating the close, discarding the
carry, dropping the promote gate, unredacting the fallback line, and
restoring the arrival-time fold each fail their pins.
2026-08-06 01:04:32 -07:00
Patrick Buckley 6212783e23 fix(832): close the content run at a reasoning_delta boundary — display must mirror the drain
Live-caught on a deployed review exercise: the consumer's reasoning_delta
arm flipped the splitter's in_think with a buffered content tail still
pending, so a flush while in-think (stream finish, tool boundary)
relabeled that tail as reasoning. The drain closes each content run at
the same boundary, so the committed turn kept the tail as content —
display and commit diverged. Worst case: a short answer followed by
trailing reasoning displayed as NOTHING while the commit carried the
answer plus its citations footer (the display-side blankness gate saw
empty content and dropped the footer too).

Pre-fold, display and commit came from one continuous splitter and both
lost the tail; the fold's drain corrected the commit, leaving the display
behind. ThinkTagSplitter.close_run() now closes the run exactly as the
drain does — decided text emits at the current state, only a possible
partial-tag tail carries into the next run — and the consumer calls it
before entering the reasoning phase. This also heals the cancelled-
partial rule in the same window, and covers the content-reasoning-tool
sequence interleaved-thinking lanes emit.

Riding contract fix: partial_tag_tail required only startswith, so a
complete <reasoning>/<think> self-matched as a "partial" tail and the
drain carried a finished open tag across the run boundary, relabeling
the next run. A partial tag is now a PROPER prefix, per the function's
own documented contract.

Pins: TestDisplayCommitMirror (displayed content must equal committed
content across six reasoning-interleave scenarios — the combination the
replay-parity grid never scripted), TestPartialTagTail contract rows,
TestCloseRun unit pins, and three new interleave rows in the splitter
CASES table. Both fixes are mutation-probed: disabling close_run or
restoring the self-match fails the pins.
2026-08-06 01:04:32 -07:00
Patrick Buckley aa4371ea99 fix(832): retire the dead attempt's armed state in the re-create window
Between a mid-stream death and the next begin_attempt there is no live
attempt, but the consumer kept the dead attempt's armed _CancelRef: a
Stop in that window re-emitted the discarded splitter carry as fresh
content behind a duplicate stream_end, and a walk-preamble failure was
classified as another armed death, replacing the operator-actionable
stream-death error. end_attempt() now pronounces the attempt dead at
partial-capture; the consumer gains a single per-attempt initializer
(_reset_attempt), a lane-free constructor (one resolve_lane walk per
turn), and a saw-chunk classifier fallback so a never-arming adapter's
mid-stream death still classifies mid-stream instead of silently
double-rendering the same lane.

Wire-preparation failures are typed at the seam: model_turn wraps
prepare_wire raises in WirePreparationError, both walk arms forward it
verbatim (no health record, no fallback walk — a session-data fault
would otherwise paint every backend degraded), the fatal formatter gets
a dedicated branch, and the re-issue ladder's last-death mask exempts
it alongside BackendAuthUnavailableError so an auth outage mid-turn is
not misdiagnosed as a network flap.

Riding fixes: the tag-scan gate gets its single spelling
(lane_scans_inline_reasoning) shared by drain and display; the
citations fold's separator+gate become a shared pair in _protocol;
_build_main_lane stops passing config_store (dead derivation — the
session's own knobs replace both values it feeds); the debug wire dump
is ruled per-invocation (the overflow-recovery re-print is the dump
that diagnoses the recovery) and pinned; dead delegates
_ensure_tool_call_ids and _finalize_provider_blocks deleted; the parity
runner adapts to the pre-fold seam signature by inspection and refuses
to record a harness-shape TypeError as a baseline; the streaming
provider fakes move to tests/_session_helpers (their tree-wide home)
and test_cancel's duplicate helper is deleted; committed parity pins
restate their rulings in full; architecture.md's circuit-breaker
section is replaced by the real passive health-tracker story and the
send-flow diagram stops attributing tool-call assembly to the display
consumer; stale pre-fold names and ragged comment paragraphs cleaned.

New pins are mutation-probed: disabling end_attempt, the saw-chunk
fallback, the auth exemption, or the WirePreparationError arm each
fails its pin.
2026-08-06 01:04:32 -07:00
Patrick Buckley 5a20916eec docs+test(832): retire pre-fold names from prose; pin the eager-append contract
The docs sweep re-points every stale reference to the deleted seam
(architecture.md's flow diagram and ladder inventory, the lowering and
anthropic docstrings, the protocol's shared-rule docstrings that
described the pre-fold dual-assembler world). The Protocol's cancel_ref
contract is strengthened from 'before the first chunk' to 'inside the
call body, before the iterator is returned' — the instant the fold's
creation-vs-midstream classifier and health recording key on — and
three real-SDK-over-mock-transport tripwires pin it per adapter, so a
future lazily-issued generator adapter fails loudly instead of silently
reclassifying every pre-first-chunk death.
2026-08-06 01:04:32 -07:00
Patrick Buckley e103af94e7 test(832): port the seam-coupled suites to the folded architecture
Seventeen files, ~1,300 tests, re-pointed or redesigned per the triage
ledger's recipes: wholesale turn-scripting moves to ModelTurnResult
fakes; streaming-behavior suites drive the REAL wrapper+consumer+drain
path through armed provider fakes (tests/_parity_832.arm_session — the
eager cancel_ref append every real adapter performs, exception elements
for creation-phase failures, sequential per-turn scripts, and the title
lane quieted: a provider-level fake otherwise loses its one-shot script
to best-effort title generation, which is why the old tests patched at
the session level); kwarg-capture suites assert through model_turn's
create_streaming call with system-prepend-aware index math; delegate
wrappers retired by the fold re-aim at their model_turn module twins.
Old-architecture pins are replaced by their new-world equivalents rather
than deleted: no shared cancel ref exists (pinned), the handle slot and
per-attempt refs carry the cancel surface, the retry gate reads the
serving lane's provider, and a superseded generation's death exits send
silently as cancelled — a named delta: no arbitrary exception class
escapes an orphaned thread anymore.

Full suite: 10651 passed, 10 skipped. The wire-payload goldens pass
untouched — the fold's lowering composition is byte-equivalent on every
provider's request path, as designed.
2026-08-06 01:04:32 -07:00
Patrick Buckley 58b24de7e6 test(832): re-aim the extra-params gate pin at the module function (delegate wrapper retired) 2026-08-06 01:04:32 -07:00
Patrick Buckley 0df9f6e2d4 test(832): port test_cancel to the folded seam; add hook + orphan + pre-dispatch pins
Provider-level armed fakes drive the REAL wrapper/consumer/drain path
(the seam these tests exist to pin), with title generation quieted — the
best-effort title lane consumed one-shot scripts once fakes moved to the
provider level. The shared-ref architecture pins become their new-world
equivalents (no shared _cancel_ref attribute; _cancel_stream lifecycle
via the eager append), and three new pin classes land: on_first_append
fires once and never for a superseded arrival; a force-cancelled
generation's mid-stream death is never re-issued and touches no UI
finalize; a pre-set Stop issues no request and mints no credential on a
dynamically authenticated alias.
2026-08-06 01:04:32 -07:00
Patrick Buckley ecf14dc001 test(832): make_result helper for the triage's patched-result recipe 2026-08-06 01:04:32 -07:00
Patrick Buckley 2e18d159a3 feat(session): fold the main streaming loop onto model_turn (#832)
The send path's plant call is now one model_turn invocation per attempt,
reached through a lane-swap fallback walk that mirrors the old creation
ladder 1:1: an inner per-lane retry (_model_turn_with_retry) inside the
two-pass healthy/degraded walk (_model_turn_with_fallback), with health
success recorded at the request-accepted instant via the per-attempt
_CancelRef's new on_first_append hook and failure once per lane ladder.
The hook is also the creation-vs-midstream classifier: an armed attempt's
death re-raises to the re-issue ladder on every lane — a fallback stream
that died after tokens reached the UI is never swallowed into
try-the-next-alias — and carries the per-turn usage-slot resets at the
old timing so a reconnecting tab's status bar never blanks mid-walk.

Chunk-to-UI translation lives in _StreamTurnConsumer (model_turn's
on_chunk body): display-side only, the canonical turn always assembled by
drain_stream at the one seam; the inline-tag scan reads the SAME lane
capability the drain gate reads (server_parses_reasoning), replacing the
creation-time handoff register — which is deleted — so display and commit
cannot disagree about a backend's posture, fallback walk included.
Cancellation converges: every model-call site now builds fresh
generation-scoped refs, closing the force-cancel hole where the old gen-0
shared ref read aborted=False for an orphaned generation and would have
let a retry re-issue on its behalf; the pre-dispatch abort read inside
model_turn also means a Stop set before the turn no longer mints a
credential on a dynamically authenticated alias.

send() consumes the result natively: the committed Turn carries minted
ids, the finalized native lane, and an accurate producer — fixing the
latent mislabel where fallback-served turns were persisted under the
primary provider's name, and the fork asymmetry where in-memory turns
decoded with producer="". Ruled behavior changes (design D12): the
trailing citations footer now folds into committed content (it previously
lived only in an ephemeral info bubble and vanished on reload); a stream
that exhausts without a finish reason is a retryable mid-stream death
instead of a silent partial commit; length-truncated turns keep dropping
partial tool calls, now as an explicit post-drain policy. The replay
parity harness pins all thirteen scenarios against pre-fold baselines,
transformed only where a ruling applies — and caught two real bugs during
the fold (the splitter's end-of-stream carry never flushing to the UI,
and the footer splicing into the answer's held tail).

ChatSession imports no provider module: create_streaming has exactly one
caller module, and the protocol types, merge_usage, and create_provider
reach the session through model_turn's re-export seam.
2026-08-06 01:04:32 -07:00
Patrick Buckley 1f3b89610a test(832): replay-parity harness + pre-fold baselines
Thirteen scenario scripts drawn from the chunk-field-to-UI grid, each driven
through the streaming seam against a scripted provider fake that arms
cancel_ref eagerly (the classifier the fold introduces distinguishes
creation-vs-midstream failures by that arming, so the fake must mirror the
real adapters' eager append). The captured records — ordered UI events,
committed-message projection, mid-stream usage, raised class — are the
OLD-WORLD baselines: this commit's session.py is byte-identical to main,
which is what makes them the record. The assert path applies only the
behavior deltas the design table rules, each transform citing its row; a
difference outside a ruled transform is a fold regression.
2026-08-06 01:04:32 -07:00
Patrick Buckley af053ab8cd feat(model_turn): streaming surface — on_chunk tee, prepare_wire hook, deferred_names, wire_msgs (#832)
model_turn gains the streaming half of its contract: on_chunk surfaces each
normalized StreamChunk through a tee upstream of the drain (the callback sees
exactly the assembler's sequence; a callback raise discards the chunk from
display and assembly alike), and DISABLES the internal drain retry — the third
policy carve-out: a partially-surfaced stream is never silently re-issued
behind a UI that already rendered its tokens; the streaming caller owns
re-issue. prepare_wire composes the caller's own deterministic lowering after
the seam passes and before the Phase-5 attach; the exact as-sent list rides
ModelTurnResult.wire_msgs for caller-side calibration. deferred_names passes
through to create_streaming (per-call state — the tool-search set grows
mid-session, so it is not a lane field). Protocol type names + merge_usage are
re-exported here so the session layer can drop its provider-module imports
when the fold lands.
2026-08-06 01:04:32 -07:00
Dennis Witt 29f1f34cf3 feat(helm): add node scheduling properties (#977)
Signed-off-by: Dennis Witt <dennis@derwitt.de>
2026-08-05 13:52:45 -07:00
Patrick Buckley 70165807c7 fix(reasoning): close the unmarked chain-of-thought leak, gate the tag scan by backend (#940) (#978)
Some serving setups emit model reasoning inline with no think tags and no
reasoning_content at all — nothing any parser can segregate (measured live
on the dev vLLM: 20/20 sampled completions, streamed and not, proxied and
direct). The drain seam correctly passes unmarked prose through, so it
became the artifact on every bounded-artifact lane: workstream titles
("Thinking Process:"), compaction summaries that were ~90% chain-of-
thought, and the web-fetch tool results #940 reports — which then ride
every following turn as context.

Three coordinated changes:

* Utility lanes ask for no reasoning. _utility_completion (title,
  compaction, web-fetch extraction) pins the alias's declared thinking
  toggle off and withholds every reasoning-effort channel — the relayed
  session knob, the lane rung, the definition default, and the graded
  template key — via lane_without_thinking / lane_thinking_suppressed,
  the same suppression omni transcription already used (now shared as
  thinking_off_template_kwargs). Measured end-to-end: the extraction
  that returned 3.7k chars of reasoning returns a 258-char answer.

* server_parses_reasoning capability. A backend that segregates
  reasoning into its own channel declares it, and the inline tag scan
  turns off on every lane: the drain seam, the interactive splitter
  (which now reads the ACTIVE stream's capabilities via the creation-
  time handoff register, never the primary alias's), and the title
  lane's cosmetic peel — so prose that merely quotes a tag can no
  longer be misrouted, and the utility suppression stands down where
  reasoning costs the artifact nothing. The built-in commercial
  capability tables declare it wholesale (known models and table-miss
  defaults); local compat lanes keep the passthrough default the scan
  exists for. Bool-typed capability overrides coerce string spellings
  instead of truthiness-flipping on hand-edited JSON.

* Title selection follows the prompt's contract, not line position:
  the last line within the word cap that ends in a word character —
  rejecting explanation sentences, sign-offs, parentheticals, and
  reasoning headings in any script (terminal punctuation carries
  unspaced scripts where whitespace word counts are meaningless) —
  else the last non-empty line. 20/20 captured live responses title
  correctly (9/20 before, unchanged since well before the seam
  unification: the old and new pipelines scored identically on every
  sample, so the regression source was the backend's output shape,
  not #965).

Also folded in from the review round: a think tag split across a
reasoning-delta boundary reassembles in the drain (partial-tag tail
carry; tool boundaries still flush), Turn.text joins text blocks with a
newline so multi-block answers stop fusing words in notification bodies
and every flattened read, the notify hook reads final_assistant_text
directly instead of through a one-line shim, web-fetch extraction uses
the shared _non_blank_or fallback, and the judge/output-guard suites use
real ModelCapabilities instead of truthy mock attributes.

Closes #940.
2026-08-05 12:58:55 -07:00
renovate[bot] 14df09a107 chore(deps): update vendored js (#974)
* chore(deps): update vendored js

* chore: download vendored JS files

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-05 12:58:15 -07:00
Patrick Buckley 7076bcf6ef fix(session): never dispatch a model call on an aborted cancel_ref (#972) (#976)
* fix(session): never dispatch a model call on an aborted cancel_ref (#972)

model_turn consulted cancel_ref.aborted before re-issuing a request after
a mid-drain transport death, but never before dispatching one. A caller
whose call had already been abandoned — the user hit Stop, or a deadline
fired — still lowered its turns, resolved its credentials, and put the
request on the wire; the provider registered the stream handle, the ref
closed it, and the client discarded a reply the endpoint had already
begun producing. The rule was half-present at the seam: don't resurrect
an aborted call was enforced, don't start one was not.

The predicate is now read before each dispatch through one helper, using
the same duck-typed getattr the drain-retry gate uses, so a None ref
(perception, title generation, sub-agents, optimizer, eval) and a
plain-list ref both stay legal. Two reads, because they buy different
things: the entry read skips the lowering and the credential resolve for
a call already abandoned when it arrives, while the read immediately
before create_streaming is the one that keeps bytes off the wire — a
blocking resolve is exactly the window the entry read is too early to
see. Cancellation stays cooperative and the docstrings say so: a mint
already under way completes, and an abort arriving after the last read
still reaches the in-flight call through the ref's own close paths
(append for a handle that has not arrived, abort for one that has).

The raise is DeadlineCancelledError, the deadline module's abandonment
vocabulary. GenerationCancelled would be invisible to the except-Exception
arms surrounding these calls, and it lives in session, which imports this
module; compaction performs the translation itself, its handler
re-checking the session before it reads the error, which is what keeps a
Stop mid-summary off the red-error path. That translation holds only
while _CancelRef.aborted and _check_cancelled stay the same predicate
over the same generation, now recorded on the property that owns it. The
raised message deliberately avoids context-window vocabulary:
_is_ctx_overflow classifies unrecognized error classes by text, and an
overflow reading would send the compaction lane subdividing and
re-issuing the very calls this suppresses.

The pre-existing abort test keeps its subject, the re-issue gate: its ref
now aborts after dispatch, and it asserts that no retry was announced
rather than counting calls, which is what separates that gate from the
post-backoff one. Two siblings pin the new reads — the resolver is never
called for a ref aborted on arrival, and an abort landing inside the
resolver still reaches no wire — and a third pins the message against the
overflow classifier.

* docs(session): disambiguate the abort helper's resolve wording

"The credential resolve between them is NOT re-checked" reads as though no
abort check follows the resolve, when the second read sits immediately
after it — the sentence meant only that nothing interrupts the resolve
itself. Left as-is it invites a refactor to delete that second read, which
is the one that keeps bytes off the wire when the abort lands mid-mint.

States both facts separately now: the mint completes regardless, and the
second read is what turns such an abort into a skipped request.
2026-08-05 11:03:12 -07:00
Patrick Buckley 0150523bb9 test(session): pin the both-vocabulary title peel
The title lane's cosmetic peel walks the close-tag vocabularies in
sequence, which review read as a double peel that could discard title
text between a `</reasoning>` and a `</think>`. It cannot: the remainder
of the first cut begins after the last `</think>`, so a `</reasoning>`
still found in it is necessarily the later tag — the sequence is
equivalent to one cut after whichever close occurs last (verified
exhaustively over tag/text arrangements and 200k randomized fragment
strings).

The equivalence was unpinned, so both orderings join the variants table
and the docstring records why the sequence is a single logical cut.
2026-08-05 00:23:11 -07:00
Patrick Buckley bc3fa60011 fix(providers): segregate inline reasoning at the drain seam
Passthrough servers (parserless vLLM/llama.cpp, LM Studio, bare
gateways) emit reasoning as literal <think>/<reasoning> blocks inside
content, and only three of nine drained lanes stripped them: web_fetch
tool results persisted raw think blocks into every following turn
(#940), judge verdicts parsed through tag noise, and a draft verdict
inside a think block could shadow the real one at the output guard.

One rule at the seam now. drain_stream accumulates content in RUNS
bounded by interleaving signals (provider-parsed reasoning deltas,
tool-call deltas) with the interactive consumer's within-chunk ordering
— reasoning, then content, then the tool-call close — and splits each
run through split_inline_reasoning, the one-shot form of the
interactive lane's ThinkTagSplitter: a pure raw split, exactly
equivalent to the streaming form on every catalog case. One trim policy
exists and the drain owns it: blank edge lines are trimmed once over
the joined runs when a tag was consumed, so tag residue dies at the
edges while genuine inter-run paragraph separators survive. Extracted
text is appended to result.reasoning after any server-parsed reasoning
with a blank-line boundary and rides the native lane as the
reasoning_text synth block. Orphan CLOSE tags deliberately pass through
byte-identical: a close whose open never arrived is indistinguishable
from prose QUOTING the tag, and drained lanes routinely quote
third-party text — reclassifying would let a malicious page containing
the literal tag destroy the extraction that cites it. The title lane
keeps a local rfind peel as display-string formatting. The citations
footer folds only onto non-blank content — sourcing for an answer that
does not exist is dropped rather than handed to emptiness checks as a
footer-only "answer".

Every private strip is deleted: the title lane's strip, the summarizer
strip, _strip_reasoning itself, and the optimizer's five regexes
(_strip_markdown_fence is now the one fence rule, applied to normalized
model output only, never to or-fallback values). Think-only and
whitespace-only responses drain to blank content, and every lane's
no-answer fallback gates on blankness: web_fetch returns an honest
extraction-error card, the intent judge takes the empty-retry ladder,
the task-agent synthesis reports "(no output)", and the optimizer keeps
the current observer system and prompt verbatim on no-answer passes.
Final-say reads (optimizer analyst, eval final_content, the notify
hook) use trajectory.final_assistant_text — the last assistant turn
only, never an earlier narration presented as the conclusion — while
last_assistant_text is the salvage walk (task_agent partial-work
recovery), skipping tool-call-only, all-reasoning, and whitespace-only
turns. Perception memoizes every completed description immediately,
including an empty one — one perceive per key, ever — under a
commit-lock guard so an empty result never overwrites a concurrently
memoized real description; an all-reasoning perception model pins the
placeholder until restart, and the remediation is server-side (a
reasoning parser or the template thinking toggle on the perception
alias). A true double-reasoning shape (inline-extracted text alongside
a native reasoning block) logs chars-only at the drain, where it is
distinguishable from the routine reasoning_delta mirror.

The dialect's semantics are pinned as one table
(tests/_reasoning_dialect.py) driven through shared fixtures
(think_tag_stream, seam_provider): one-shot conformance, the exact
one-shot/streaming equivalence property, the drain seam rules including
quoted-tag safety, run-boundary and separator-preservation pins,
per-lane pins for all nine lanes, and the empty-content assistant wire
shape.

Closes #965. Closes #940.
2026-08-05 00:23:11 -07:00
Patrick Buckley 1d7db73305 fix(models): review feedback — separator vocabulary, constraints stub, import style
The scopes sanitize now shares the registry guard's separator
vocabulary: tab/newline/CR read as spaces, and every other C0 byte —
including the U+001C–U+001F block str.split() would silently promote to
separators — strips like the control it is, so a control byte inside a
token can never split it into two valid-looking scopes (pinned
alongside the registry's refusal).

The livepass auth-constraints stub serves the new
app_identity_auth_modes field so the pass exercises the served-data
path for the model list's auth badge, and the session-module import in
the mint tests drops to the string-path monkeypatch spelling
(single-style imports).
2026-08-04 05:19:03 -07:00
Patrick Buckley 8605c9783d feat(models): rfc8693_obo auth mode, per-alias exchange scopes, identity-keyed mint cache
Adds the dedicated `rfc8693_obo` model auth mode (#955): model
definitions gain an `obo_scopes` column (migration 069), the mint
threads the scopes to the token-exchange leg (RFC 8693), and every
dynamic mode pins its grant leg — a mode is a dialect commitment, not a
hint the deployment profile resolves. Exchange-capable IdPs refuse an
audience whose scope was not requested; this closes the structurally
unmintable model-OBO path on token-exchange deployments.

The model mint-cache is identity-keyed on the owning definition's
alias (`__model_obo__:<alias>` per user, `__model_app__:<alias>` under
the shared app principal), matching the MCP discipline where rows key
on the unique server name. The bearer's shape lives in the row's
audience/scopes columns and the freshness gate compares it on every
read, so a re-aimed alias refuses its old row and overwrites the same
key in place. Admin lifecycle (rename, re-aim, scope change, delete)
purges a definition's own rows through one shared helper — sound
because one definition owns each key; a sibling's rows are untouchable
by construction. Cooldown and backoff additionally key on the dispatch
shape, so an operator's config repair is an instant clean slate. Cause
records, cooldowns, locks and memoization are per-alias end to end,
and the session heartbeat reads refusal causes under the same keys.

Console: default-deny write gating for dynamic rows (value-diff over
the full column ladder, admin.mcp escalation, a never-blockable
pure-disable carve-out), a two-tier validator (audience allow-list on
every write; deployment-posture checks when the pair is chosen), one
shared scopes parser whose omit-unchanged arm keeps over-cap DB-direct
residue rows disarmable without ungating real changes, and served
constraints (dynamic/scopes/app-identity mode lists, mode-to-profile
pairing) so the shelf tracks the registry by data. The admin shelf
gains the mode option, a scopes input with residue affordances,
pairing-aware option greying, and a derived auth badge.

Registry load refuses control characters in alias, audience, and
scopes — including the C0 separator block that str.split() would
silently collapse — and the C0/DEL class has one exported spelling
shared by every surface. Profile-mismatch visibility warns at reload
and boot with the mode-correct cause, gated on OIDC being enabled.

Breaking: a stored `entra_obo` alias on a deployment whose
`[oidc] obo_grant_profile` is `rfc8693` (or the inverse pairing) no
longer mints via the profile-driven overload — the mint refuses before
any IdP traffic with cause `grant_profile_mismatch`, and the
`model.auth_fail_closed` policy governs static fallback. Such rows
never minted usefully on scope-gating IdPs; the shelf now surfaces the
pairing and the per-turn heartbeat names the refusal cause.

Live-verified end to end: scoped token exchange mints, the warm cache
serves with zero IdP calls, and the mode/profile mismatch refuses with
zero IdP traffic (scripts/obo-e2e/keycloak_e2e.sh); the
refresh-redemption profile's E1-E7 hold via scripts/obo-e2e/entra_e2e.py.

Closes #955.
2026-08-04 05:19:03 -07:00
Patrick Buckley 2b43b8dd90 fix(streaming): correlate the fatal trace line with its recorded event
The DEBUG trace for a fatal turn now carries ws and error_type,
mirroring the ERROR-level session.fatal.recorded line — without them a
stack trace under concurrent sessions correlates to its fatal event by
timestamp guesswork only. Frames-only rendering is unchanged (the
sanitize floor: no exception message text in the journal).
2026-08-04 04:53:17 -07:00
Patrick Buckley 7776cc0c2f fix(streaming): probe on_stream_discarded for pre-existing UIs and format the hoisted fake
PR feedback round:

- on_stream_discarded now follows on_compaction's compat pattern for a
  hook added after UIs exist in the wild: the protocol member carries a
  REAL no-op default (an explicit subclass inherits a correct
  implementation — a UI without server-side turn buffers has nothing to
  truncate), and both call sites route through a getattr probe, so a
  duck-typed UI predating the hook degrades to no-truncate instead of
  raising an AttributeError from the very arm that is handling a stream
  death — which would replace the wire failure with the attribute error
  in the retry gate. Pinned with a hook-less-UI retry test.
- tests/_session_helpers.py gains the formatting pass the RecordingUI
  hoist bypassed (the CI lint failure).
2026-08-04 04:53:17 -07:00
Patrick Buckley 1f9f462b66 fix(streaming): gate the dead-segment discard on the backoff surviving the Stop window
Fifth review round — four small correctness edges, none in the retry
semantics:

- The server-buffer discard now runs only AFTER the backoff survives a
  Stop: a cancel during the window persists the promoted partial to
  history, and the idle-state payload (drained from the turn buffer)
  must carry the same text — discarding first rendered the cancelled
  turn empty on the dashboard while the transcript had it. Pinned with
  a real-buffer test; the spinner and fresh segment watermark follow
  the truncate so a later discard cannot resurrect the dead segment.
- stream.retry's dead_content_chars reports THIS death's flushed text
  only — the Stop-preservation carry retains the previous attempt's
  partial by design, and logging its length re-attributed the same
  discarded spend to consecutive retry lines.
- The changelog entry for the post-finish-blip rename no longer claims
  the usage_captured field was dropped; it is emitted and pinned.
- The retry suite's module docstring states the shipped finalize
  contract (stream_end + backoff-gated stream_discarded, never
  turn_committed) instead of the superseded pair.
- RecordingUI is hoisted into tests/_session_helpers next to NullUI —
  this branch already paid the per-file-fake tax once when a protocol
  method grew — and a stale deferral sentence is dropped from the
  fatal-formatter comment.
2026-08-04 04:53:17 -07:00
Patrick Buckley 961a2017dc fix(streaming): delete the retry window's shared slots and gate the send epilogue
Fourth review round. The recurring defect family — cross-frame session
slots racing an orphanable window — is removed structurally instead of
gated again:

- The wire-fold slot is deleted. The fold the stream was actually
  created from rides the returned message dict on the underscore lane
  (like _provider_content) and is popped at the single calibration site
  before commit, so a superseding generation can never alias it and
  there is nothing left to clear. Plain-dict test fakes fall through the
  pop to the frame-local fold.
- The stream-provider slot is demoted to a creation-time handoff
  register: _try_stream stamps it, _stream_response copies it into a
  frame-local immediately after each create returns, and only that
  local feeds the retry gate. The fatal formatter returns to the
  consistent PRIMARY identity triple — pairing a fallback's provider
  name with the primary's base_url and alias sent operators to debug
  the wrong backend; stamping the full producing identity is #964.
- send()'s epilogue is generation-gated: a superseded thread's escaped
  death no longer records a fatal error over the healthy successor turn
  (error banner, buffer-wiping error-state drain, wrong last_error for
  the coord), and a Ctrl-C on an orphan no longer mutates history.
- The terminal arm discards as well as finalizes. Keeping the buffers
  bought nothing — the fatal path's error-state drain wipes them on
  every server lane — and the skipped discard let a mid-consumption
  overflow recovered by compact-and-retry concatenate the dead
  attempt's text with the recovered answer in the idle payload. Pinned
  with real-buffer tests for the overflow-recovery and orphan-epilogue
  paths.
- stream.post_finish_blip regains usage_captured, tracked by
  transport_guarded from the chunks it forwards, restoring
  missing-spend attribution on both lanes.
- TerminalUI.on_thinking_start is idempotent at the callee (a live
  spinner is stopped before being replaced), removing the caller-side
  stop-first dance and the leak the next unaware call site would have
  reintroduced.
- The think-tag vocabulary in _strip_reasoning and the title lane is
  derived from ThinkTagSplitter, closing the drift channel that would
  leak raw reasoning into compaction summaries and titles.
- on_stream_discarded's docstring states the true pending-batch
  semantics (defensive drop; the shipped sequence flushes via the
  preceding stream_end), and the live-suite recording fake gains the
  protocol method.
2026-08-04 04:53:17 -07:00
Patrick Buckley 476cce2e58 fix(streaming): scope stream bookkeeping to the send and discard dead segments server-side
Third review round on the retry window: two mediums fixed, one
observability gap closed.

- New UI-protocol method on_stream_discarded(): on_turn_committed clears
  only the inflight buffers — it cannot clear _ws_turn_content, the
  multi-segment buffer the IDLE payload drains, because earlier segments
  of a tool-looping turn must survive commits — so a dead attempt's text
  concatenated with the retried text in the dashboard's idle payload.
  SessionUIBase now truncates the turn buffer to a segment watermark
  (snapshotted in on_thinking_start, which precedes every stream
  segment), drops the never-displayed pending batch, and resets the
  inflight snapshot; the retry arm emits it in place of
  on_turn_committed. Server-side only — no SSE event, no client change;
  no-op on the CLI and eval UIs. Pinned with a real-SessionUIBase-buffer
  test: the recording fakes structurally cannot see this buffer.
- _active_stream_provider and _active_wire_msgs are send-scoped: cleared
  in send()'s finally, after the except arms' fatal formatting (the one
  legitimate fatal-path reader of the provider field). A later fatal on
  a utility lane falls back to self._provider instead of wearing a stale
  interactive-turn binding, and the full-context-sized wire fold no
  longer outlives its calibration use.
- stream.retry carries dead_usage and dead_content_chars: the abandoned
  generation's billed tokens are otherwise invisible (the wire reports
  usage only at stream end — Anthropic's early prompt tokens arrive, the
  OpenAI chat lane's usage chunk trails the finish), so the log line
  records what the wire delivered plus the discarded completion's char
  count for spend reconciliation.
2026-08-04 04:53:17 -07:00
Patrick Buckley 47524654b3 fix(streaming): close the retry window's generation, identity, and masking holes
xhigh review round on the mid-stream retry ladder: 14 verified correctness
findings, all fixed, plus the verified-but-capped cleanups mined from the
review run.

Generation safety — the shared-slot class is removed structurally, not
gated per site: a dead attempt's partial now rides the raised exception
(thread-private by construction) into a wrapper-local variable, and the
_midstream_dead_partial session slot is deleted, so an orphaned superseded
generation cannot poison a live generation's preservation. The promotion
helper is generation-gated, writes the marker row even for a pre-token
death (empty content takes the marker-as-message branch), and backfills a
recorded-but-empty partial with the previous attempt's text, so a Stop
anywhere in the retry window — backoff, re-create, or TTFT wait —
preserves the latest text the user actually saw. _record_cancelled_partial
is generation-gated too: a superseded thread touches neither the UI nor
the shared slot.

Identity — the retry gate and the fatal formatter now consult the provider
that actually owns the live stream (recorded at creation, covering the
fallback walk by construction), so a fallback stream's provider-specific
transient is retryable by ITS OWN contract and failures are labeled with
the binding that produced them. The mid-retry rebind check compares the
full (client, model, provider) binding — reload() keeps the pooled client
on model-only swaps — and a re-prepare also re-exports the wire fold that
send()'s token-table calibration counts.

Masking — a context overflow raised by the mid-retry re-create surfaces as
itself so the compact-and-retry arm can recover the turn, and the overflow
arm is split: recovery-machinery failures still surface the original
overflow (its wording anticipates them), while post-compaction consumption
failures surface as themselves instead of a false overflow diagnosis.

Cancellation and terminal paths — a Stop that races the trailing-metadata
window is re-checked after the chunk loop, so the turn aborts with the
marker instead of committing and running its tool calls; the terminal arm
finalizes client-side only, deliberately keeping the in-progress snapshot
(the unpersisted partial's only copy) for refresh-replay; KeyboardInterrupt
gets the same client-side finalize; the retry arm stops the spinner before
restarting it (the CLI's on_thinking_start replaces the spinner without
stopping it — a thread leak); and the backoff delay is computed from the
pre-increment index, matching the sibling ladders' convention.

Mined cleanups: the retry suite wraps the shared session factory instead
of duplicating its defaults; the usage projection uses dataclasses.asdict;
the partial-content rule lives in one closure serving both preservation
paths; the two fatal-log tests are parametrized into one; the test import
uses the public providers package.
2026-08-04 04:53:17 -07:00
Patrick Buckley df81035302 fix(streaming): finalize dead attempts on terminal paths and harden the retry window
External-review round on the #937 branch; four confirmed findings fixed,
each on a failure path the retry loop itself introduced or made reachable:

- The terminal arm (retry exhaustion, non-retryable death) now finalizes
  the dead attempt with the same stream_end + turn_committed pair the
  retry path emits, so the last attempt's partial is flushed in every
  consumer — the CLI was the exposed case (its markdown fence state
  resets only in on_stream_end; the server workers emit their own after
  a fatal, the CLI's direct send() does not).  The finalize is gated
  behind the generation check: an orphaned superseded thread must not
  emit UI events over the new generation's stream.
- A Stop landing in the backoff/re-create window now preserves the dead
  attempt's partial: the attempt stashes its flushed content (plus the
  content-state carry tail) on a non-cancel death, and the wrapper
  promotes the stash to the cancelled-partial slot before re-raising, so
  send()'s cancel handler persists it with the cancellation marker —
  the same disposition a cancel during the attempt gets.
- The fatal-path debug trace logs frames only (format_tb): exc_info
  rendered the raw exception message, which can carry credentials
  verbatim — the exact leak the sanitize floor above it exists to hold.
  The recreate-failure warning drops exc_info for the same reason and
  logs the exception class name instead.
- A mid-retry rebind that replaced the client re-prepares the wire
  messages against the new binding before re-issuing: the system-turn
  fold is capability-sensitive, and a registry reload that switched
  model family would otherwise re-send the old family's wire shape.

The cross-thread close boundary pin now accepts ReadError or
RemoteProtocolError: which one surfaces is platform/timing-dependent,
and both are TransportError members of the stream-death set, which is
the property the pin exists for.
2026-08-04 04:53:17 -07:00
Patrick Buckley 3b9de67e8c refactor(session): extract think-tag splitting into ThinkTagSplitter
The interactive chunk consumer's _flush_text/_drain_pending closure pair
carried the partial-tag carry buffer and in-think state inline. The
tag-scanning half moves to turnstone/core/streaming_text.py as a
standalone ThinkTagSplitter (carry buffer, in_think state, earliest-
index tag selection, MAX_TAG_LEN safe-flush); dispatch and accumulation
stay in the session behind the emit callback, and out-of-band
transitions (reasoning_delta path, tool-call starts, cancellation)
read/write splitter.in_think and flush_pending() where they previously
touched the closure locals.

Pure move: table-driven pins covering partial-tag buffering across
chunk boundaries, the safe-flush margin, open/close tag precedence,
in_think transitions, and reasoning-vs-content dispatch were written
against the closure implementation and pass unchanged against the
extracted class — byte-identical emitted text, identical UI callback
ordering. The session-level _THINK_*/_MAX_TAG_LEN class constants fold
into the class.
2026-08-04 04:53:17 -07:00
Patrick Buckley a1dfe0bd4f refactor(streaming): dedupe transport conversion, usage merge, cancel finalize
Three behavior-preserving consolidations behind the #937 fix, each
deleting a hand-rolled twin of a now-shared rule:

- drain_stream consumes transport_guarded(chunks) and drops its inline
  `except httpx.TransportError` arm — one conversion rule for mid-body
  wire deaths across the drained and interactive lanes. The post-finish
  tolerance now logs under the wrapper's `stream.post_finish_blip` name
  (formerly `drain_stream.post_finish_blip`) and no longer carries
  `usage_captured`; changelog notes the rename for external log
  filters. The possible usage=None result on a post-finish blip is
  documented on drain_stream itself.
- _stream_attempt's hand-rolled per-chunk usage max-merge becomes a
  local UsageInfo accumulator folded through merge_usage (drain's
  rule), re-projected into the _last_usage dict on EVERY usage chunk —
  that dict has mid-stream readers (_estimated_prompt_tokens, the
  status line), so the per-chunk write timing is load-bearing and
  unchanged.
- The twin cancelled-partial sequences in _stream_attempt's two cancel
  arms (cooperative GenerationCancelled, stream-close-converted) merge
  into one local _record_cancelled_partial helper carrying both arms'
  tool_calls/_provider_content omission rationale in one place.
2026-08-04 04:53:17 -07:00
Patrick Buckley 5fb27e8f81 fix(session): survive mid-stream transport deaths in interactive turns (#937)
A wire death during body streaming (ReadError on a TLS record failure,
peer resets) surfaces after the request has already returned its stream
handle, so neither the SDK's request retries nor the creation-time
retry ladder ever saw it: the interactive turn died with a bare
exception string, the partial output was discarded, and no log trace
was left. Utility lanes already survived this through drain_stream's
normalization; the interactive loop now gets the same treatment.

- transport_guarded() in providers/_protocol.py: drain_stream's
  transport-death conversion made reusable for consumers that keep
  streaming semantics. Pre-finish deaths raise the retryable
  IncompleteStreamError (drain's exact message shape); post-finish
  blips end the stream cleanly, forfeiting only trailing metadata.
- The single-pass chunk consumer renames to _stream_attempt;
  _stream_response is now the resilient wrapper owning ALL stream
  acquisition plus a bounded mid-stream re-issue ladder
  (_MID_STREAM_RETRIES, the shared _stop_retrying predicate with a
  per-loop cap, cancel-aware exponential backoff). Send()'s overflow
  compact-and-retry arm now wraps the whole turn and passes re-prepared
  msgs explicitly.
- A dead attempt is finalized across every UI consumer before the
  retry (stream_end then turn_committed then notice then spinner), so
  retried text never appends onto the dead attempt's in any surface
  (browser transcript, CLI markdown fences, Slack/Discord streamed
  messages, SSE replay ring).
- Before re-creating, the session re-resolves its registry binding: a
  concurrent ModelRegistry.reload() closes cached clients, and the
  retry must not stream into the closed one. A failing re-create logs
  stream.retry.recreate_failed and re-raises the ORIGINAL stream-death
  error rather than masking it.
- _format_backend_error gains a stream-death branch naming the
  provider, endpoint, and model, with a short identity-bearing first
  sentence. _BACKEND_STREAM_EXC_NAMES joins _BACKEND_KNOWN_EXC_NAMES,
  which also removes those names from _is_ctx_overflow's text-detection
  eligibility (deliberate: their texts are fixed transport strings that
  never carry overflow phrases).
- _record_fatal_error now logs session.fatal.recorded (INFO for
  KeyboardInterrupt, ERROR otherwise) so fatal turns leave a journal
  trace.
- _assistant_pending_tokens resets at stream entry so a post-finish
  blip that loses the trailing usage chunk cannot append the previous
  turn's completion count as this turn's estimate.

Offline SDK boundary pins (openai/anthropic mid-body death identity and
no re-request, cross-thread client close surfacing httpx.ReadError)
guard the assumptions the retry gate rests on.
2026-08-04 04:53:17 -07:00
Patrick Buckley 1e34e19d48 refactor: single-style module imports and narrowed JSON body typing
Consolidates the repeated function-local model_registry imports onto one
from-style module import per test file (the module object stays available
for monkeypatching), converts the e2e script's mcp_oauth import to match,
and reads the request body as Any before the isinstance narrow so the
declared dict type is earned rather than asserted.

Addresses the automated review feedback on the pull request; the two
code-scanning flags are dismissed as false positives separately (the
missing-key refusal log names config knobs and carries no secret value;
the URL assertion is a test expectation, not a sanitizer).
2026-08-03 20:11:28 -07:00
Patrick Buckley 33ace975d2 feat(models): default-deny governance and admin UI for per-alias backend auth
Follow-up to the per-alias Entra OBO/app-identity backend auth: the
console write path now applies default-deny field classification, the
admin shelf gains full backend-auth support, and the session/registry
rebind machinery is hardened for config changes landing under live
sessions.

Console write gate:
- Default-deny classification: any non-neutral change to a row that is
  or becomes dynamic requires admin.mcp plus validation; the provably
  auth-neutral columns are enumerated (MODEL_AUTH_NEUTRAL_FIELDS) and a
  live-schema classification test forces every future column to be
  classified. The derivation is a pure function (_derive_auth_gate)
  with unit-pinned exclusivity invariants.
- Two-tier validation mirroring the MCP oauth_obo validator: the row
  tier (audience allow-list) runs on every gated write; the posture
  tier (OIDC configured, token store present) runs on pair changes and
  on enable-arming.
- Pure-disable carve-out: disabling a dynamic row is de-escalation and
  is never blocked — admin.models suffices and validation is skipped,
  including for rows with corrupt or skewed stored values.
- Capabilities are compared canonically (key order, integral floats),
  the audience compare normalizes both sides, and staging an audience
  on a static row is refused on both write twins.
- Calibrate writes the capabilities column under an enforced
  confinement invariant with a compare-and-swap persist.

Admin shelf:
- Backend-auth section with a per-open constraints fetch
  (GET /model-definitions/auth-constraints: audience allow-list, grant
  profile, dynamic modes), datalist audience suggestions,
  server-defined modes preserved on round-trip, and permission-aware
  visibility built on cache-skew-safe helpers shared through auth.js.
- Refused live-registry swaps surface as an amber registry_warning on
  the write, delete, reload, and calibrate responses; audit rows carry
  auth_gated / auth_disarmed markers visible in the audit view.

Registry and sessions:
- The encryption-key requirement for dynamic auth is enforced inside
  ModelRegistry.reload() itself — nodes refuse with 503 and the
  console records coord_registry_error — and reload bumps the
  generation before the map swap so a racing reader can never pair a
  stale generation with new maps.
- resolve()/resolve_binding() return the generation from inside the
  registry lock; sessions rebind per send on generation change with
  atomic client/provider/config commits, fallback-first handling of
  removed or unconstructable aliases, and judge/limiter resets only
  when the binding actually changed.
- Mint refusals record per-user causes surfaced in the per-turn
  heartbeat logs; misconfiguration warnings are deduplicated with
  bounded state.

Verification: 10417 tests (99 added on this branch), a 71-scenario
browser harness over the real admin shelf, and a live rfc8693
token-exchange e2e run (MCP legs verified end to end; the model-leg
scope gap is tracked as #955 under a narrow known-gap signature).

Closes #950.
2026-08-03 20:11:28 -07:00
renovate[bot] 1a4f411cd5 chore(deps): lock file maintenance 2026-08-03 11:38:31 -07:00
renovate[bot] 5bc04fc313 chore(deps): update github actions (#956)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 02:34:54 -07:00
metaclassing 9adde920d4 feat(models): per-alias backend auth via Entra OBO and app identity (#898)
Adds a per-alias `auth_mode` on model definitions so a model backend can
authenticate to an Entra-fronted gateway with a per-request minted token instead
of one shared static API key, letting the gateway attribute calls to the actual
user or to the app as a machine identity.

- `static` (default, unchanged) sends the stored `api_key`.
- `entra_obo` mints a per-user On-Behalf-Of token for `obo_audience` from the
  caller's captured refresh credential.
- `entra_app` mints an app-identity token via the client-credentials grant, and
  covers userless turns that OBO cannot.

Reuses the existing OBO grant legs, refresh-token rotation CAS, cluster advisory
lock and the `mcp_user_tokens` mint-cache, keyed under synthetic
`__model_obo__:<audience>` / `__model_app__:<audience>` rows. The token binds at
the call site through `client.with_options(api_key=...)` so each SDK emits it on
its own auth path rather than through header injection.

Migration 068 adds `auth_mode` and `obo_audience`. Both are additive and existing
rows default to `static`, so behaviour is unchanged unless an alias opts in.

Operator controls: `model.auth_audience_allowlist` is an exact-match allow-list
that gates which audiences may be configured and denies all by default, and
changing a mode or audience requires `admin.mcp`. `model.auth_fail_closed`
decides whether a failed mint may fall back to an explicitly configured static
key. A delegated call with no user, or a dynamic alias with no real static key,
always refuses.

Two changes here apply regardless of whether any alias opts in:

- Storage and app state are now wired into the console MCP client manager. This
  fixes per-user `oauth_user` / `oauth_obo` dispatch for coordinator-hosted
  sessions, which previously raised `RuntimeError` on first call because
  `set_app_state` was only ever called on the node.
- Unattended watch restores and `--resume` resolve the persisted workstream
  owner instead of constructing the session under an empty principal. A
  workstream with no owner is now a permanent refusal rather than an anonymous,
  auto-approved run.
2026-08-02 15:15:02 -07:00
Patrick Buckley 9334cf0cef fix(helm): make the bundled-PostgreSQL default installable (#949)
* fix(helm): render the chart Secret for every inline credential

Setting llm.existingSecret suppressed the chart's whole Secret, not just
the LLM API key it replaces. POSTGRES_PASSWORD and TURNSTONE_JWT_SECRET
went unrendered with it while server, console and the migrate Job went on
referencing them, so every pod stalled in CreateContainerConfigError.
Supplying an LLM Secret is a supported, documented configuration, and it
took the install down on both the bundled and external database paths.

turnstone.db.secretName compounded it by falling back to
turnstone.llm.secretName, pointing the password lookup at the operator's
LLM Secret — which has no reason to carry a database password.

Both now derive from one predicate. turnstone.db.inlinePassword returns
the password when the chart stores it itself and empty when an operator
supplies it, so secret.yaml renders on exactly the condition under which
turnstone.db.secretName resolves to <fullname>-secrets. The two cannot
disagree about where the password lives, which is what the earlier
llm.secretName fallback was working around. Each key keeps its own
condition, so an existingSecret still suppresses the value it replaces
and nothing else.

Verified by rendering nine values permutations against both this and the
previous templates and diffing every secretKeyRef against the Secrets
each tree creates: three permutations fixed, six byte-identical, none
regressed. helm lint passes on all nine.

The bundled-PostgreSQL default is unaffected and still broken: the
subchart generates its password into <fullname>-postgresql, which the
chart never reads. It is separately blocked by the migrate hook running
before the database exists, so it needs the design decision called for
in #932 rather than a secret-name change.

* fix(helm): default the inline password so an unset key cannot become one

turnstone.db.inlinePassword is reached through include, which captures
rendered text rather than a value. A key that is unset rather than empty
— "password:" with nothing after it, or --set database.external.password=null
— renders as the literal "<no value>", and a ten-character string is
truthy, so it satisfied the gate in templates/secret.yaml and landed
base64-encoded in POSTGRES_PASSWORD. Workloads then authenticated with
the string "<no value>".

Reaching the values through default "" keeps unset and empty equivalent,
which is what the previous templates got for free by testing the value
directly instead of the rendered text. Introduced by the commit before
this one; caught in review.

The two null spellings are now permanent cases in the render matrix.
Across eleven permutations, three are fixed relative to main, eight are
byte-identical, none regress, and the inline password still round-trips
byte-exact. helm lint passes on all eleven.

* docs(helm): narrow the inlinePassword guarantee to what it holds

The comment claimed secret.yaml and turnstone.db.secretName cannot
disagree about where the password lives. That holds wherever the chart
or the operator supplies the password, but not where the bundled
subchart generates its own — that lands in the subchart's Secret, which
neither helper reads. State the two guarantees that do hold instead.

* fix(helm): make the bundled-PostgreSQL default installable

The default values have never produced a working install. Two faults,
and the first is why the second could not be fixed on its own.

The migrate Job ran as a pre-install hook, and Helm creates ordinary
resources only once hooks have finished. On a first install that means
none of what the migration needs exists yet: not the ConfigMap, not the
Secret, and — because the subchart is an ordinary resource — not the
database either. #932 worked around the first two by dropping the Job's
ServiceAccount reference and inlining its environment, but nothing can
work around the third: no reference to the subchart's Secret, however
derived, is readable by a hook that runs before the subchart exists.

So the Job moves to post-install, and to pre-upgrade rather than
post-upgrade: on an upgrade everything is already running, and
migrations belong before the new code rolls out rather than after. Helm
does not wait for readiness before post-install hooks, so the Job's own
retry is what waits for a cold database, and backoffLimit rises to cover
an image pull and cluster initialisation.

That in turn unwinds the workarounds. The Job takes the chart's
ServiceAccount back, and templates/secret.yaml drops the hook
annotations it was given so the pre-install Job could read it — those
made it a hook resource, untracked by the release, so the credentials
survived helm uninstall and were skipped by helm rollback.

With ordering fixed the password resolves properly. When the subchart
generates its own, turnstone.db.secretName now points at the subchart's
Secret instead of at <fullname>-secrets, which never carried the key.
The naming is mirrored rather than delegated, since the subchart's
helpers expect a context this chart cannot hand them, and it is derived
from the release name: a fullnameOverride here renames this chart's
resources and leaves the subchart's alone, so "<fullname>-postgresql"
would name a Secret that does not exist.

Verified across fifteen values permutations against origin/main: nine
fixed, six byte-identical, none regressed, helm lint clean on all
fifteen. The permutations cover both fullnameOverride spellings, a
subchart existingSecret with a renamed key, and the superuser key rule.

An external database with no password and no existingSecret is unchanged
and still fails at pod start. Passwordless authentication is not
something the chart models — the URL always references a password — so
that stays as it was rather than becoming a template-time error.
2026-08-02 14:52:31 -07:00
Patrick Buckley 989f51edc5 fix(helm): render the chart Secret for every inline credential (#948)
* fix(helm): render the chart Secret for every inline credential

Setting llm.existingSecret suppressed the chart's whole Secret, not just
the LLM API key it replaces. POSTGRES_PASSWORD and TURNSTONE_JWT_SECRET
went unrendered with it while server, console and the migrate Job went on
referencing them, so every pod stalled in CreateContainerConfigError.
Supplying an LLM Secret is a supported, documented configuration, and it
took the install down on both the bundled and external database paths.

turnstone.db.secretName compounded it by falling back to
turnstone.llm.secretName, pointing the password lookup at the operator's
LLM Secret — which has no reason to carry a database password.

Both now derive from one predicate. turnstone.db.inlinePassword returns
the password when the chart stores it itself and empty when an operator
supplies it, so secret.yaml renders on exactly the condition under which
turnstone.db.secretName resolves to <fullname>-secrets. The two cannot
disagree about where the password lives, which is what the earlier
llm.secretName fallback was working around. Each key keeps its own
condition, so an existingSecret still suppresses the value it replaces
and nothing else.

Verified by rendering nine values permutations against both this and the
previous templates and diffing every secretKeyRef against the Secrets
each tree creates: three permutations fixed, six byte-identical, none
regressed. helm lint passes on all nine.

The bundled-PostgreSQL default is unaffected and still broken: the
subchart generates its password into <fullname>-postgresql, which the
chart never reads. It is separately blocked by the migrate hook running
before the database exists, so it needs the design decision called for
in #932 rather than a secret-name change.

* fix(helm): default the inline password so an unset key cannot become one

turnstone.db.inlinePassword is reached through include, which captures
rendered text rather than a value. A key that is unset rather than empty
— "password:" with nothing after it, or --set database.external.password=null
— renders as the literal "<no value>", and a ten-character string is
truthy, so it satisfied the gate in templates/secret.yaml and landed
base64-encoded in POSTGRES_PASSWORD. Workloads then authenticated with
the string "<no value>".

Reaching the values through default "" keeps unset and empty equivalent,
which is what the previous templates got for free by testing the value
directly instead of the rendered text. Introduced by the commit before
this one; caught in review.

The two null spellings are now permanent cases in the render matrix.
Across eleven permutations, three are fixed relative to main, eight are
byte-identical, none regress, and the inline password still round-trips
byte-exact. helm lint passes on all eleven.

* docs(helm): narrow the inlinePassword guarantee to what it holds

The comment claimed secret.yaml and turnstone.db.secretName cannot
disagree about where the password lives. That holds wherever the chart
or the operator supplies the password, but not where the bundled
subchart generates its own — that lands in the subchart's Secret, which
neither helper reads. State the two guarantees that do hold instead.
2026-08-02 14:42:33 -07:00
Patrick Buckley f8f2ba03d3 docs(contributors): add five contributors from the last four months
The list had not been revised since 2026-06-10, and then only incidentally
as part of the relicense commit. Cross-checking commit authorship against
the full merged-PR list surfaced five people with merged work and no entry:
metaclassing, posixpositive, Sanjay Santhanam, Stefano Maffeis and
BlackMyrmidon.

The two scans agree exactly once pow3rtool (a machine account that authored
the #741 commit) is folded into metaclassing. Authorship alone is not
sufficient — squash merges can land an external PR under the committer's
name — so the merged-PR author list is the cross-check.

Ordering follows the existing convention: named entries alphabetically by
display name, handle-only entries after them.
2026-08-02 13:54:26 -07:00
posixpositive 73fb84b459 fix(helm): make the Kubernetes chart installable and multi-node capable (#932)
* fix(helm): repair install-blocking template bugs

The chart could not complete `helm install` in any cluster. Three
independent faults, each hit in sequence on a clean namespace:

1. The console Deployment never set TURNSTONE_DB_URL. The console
   requires it (console/server.py exits with "Storage backend is
   required for the console") so the pod could never start. Only the
   server Deployment defined it.

2. The migrate Job is a pre-install hook but referenced the chart's
   ServiceAccount. Helm creates ordinary resources only after hooks
   complete, so the Job could never be scheduled:

     Error creating: pods "turnstone-migrate-" is forbidden: error
     looking up service account <ns>/turnstone: serviceaccount
     "turnstone" not found

   The migration talks to PostgreSQL and never to the Kubernetes API,
   so it now runs under the namespace default ServiceAccount.

3. The same Job took its config via `envFrom` on the chart's ConfigMap
   and Secret -- also ordinary resources -- so once (2) was fixed it
   failed with:

     Error: configmap "turnstone-config" not found

   The Job is now self-contained. Where it still needs the chart's own
   Secret for POSTGRES_PASSWORD, that Secret carries matching
   pre-install/pre-upgrade hook annotations at a lower weight (-3 against
   the Job's -1) so it exists by the time the hook runs.

Also wires up two values that were documented but referenced by no
template: database.external.existingSecret and database.external.sslmode.
An external database frequently keeps its password in a secret the chart
does not own (CloudNativePG, External Secrets, ...), where the key is
rarely named POSTGRES_PASSWORD, so existingSecretPasswordKey is added
alongside. sslmode is appended to the URL only on the external path.

The shared turnstone.db.env helper renders every connection value inline
rather than relying on envFrom expansion, which is what lets the hook
stand alone; the server, console and Job now cannot drift apart. Its
secret-name fallback resolves through turnstone.llm.secretName rather
than hardcoding "<fullname>-secrets", because templates/secret.yaml is
skipped entirely when llm.existingSecret is set -- hardcoding it would
point every workload at a Secret that is never created.

Verified against an external CloudNativePG cluster: `helm install`
completes, the migration creates all 45 tables, and both workloads reach
PostgreSQL over TLS. `helm lint` passes, and every referenced Secret is
either chart-created or operator-supplied, across the bundled,
bundled+llm.existingSecret, external+inline-password and
external+existingSecret paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(helm): advertise per-pod URLs so multi-node routing works

Neither workload advertised an address peers could reach, so the console
could not talk to server nodes at all and server.replicas > 1 was
unusable.

Server nodes register in the `services` table and the console routes to
them with rendezvous (HRW) hashing: route(ws_id) picks exactly one node
and proxies to that node's advertised URL. The chart set nothing, so a
node fell back to gethostname() -- the pod name -- which nothing in the
cluster can resolve, and the console's SSE collector could never attach.

The fix cannot be the Service DNS name: that load-balances across every
replica, so traffic the router computed for node A lands on an arbitrary
pod. With three replicas that produces a steady stream of 404s through
the router's retry path. Each pod now advertises its own pod IP via the
downward API, which is unique, routable in-cluster on any CNI, and
re-registered on every start.

The console is the opposite case -- one logical endpoint behind its
Service -- so it advertises the Service DNS name via TURNSTONE_CONSOLE_URL.
That name stops at ".svc" rather than assuming a "cluster.local" DNS
domain, which is configurable per cluster.

Verified at server.replicas=3: all three nodes register distinct
addresses, and six workstreams created through
/v1/api/route/workstreams/new distribute across the ring and complete
real inference turns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(helm): use Recreate for the single-replica console

Workaround for a service-registry race, kept as its own commit so it can
be dropped if the underlying bug is fixed in the application instead.

The console registers itself under the fixed service_id "console" and
deregisters on shutdown. Under RollingUpdate the incoming pod registers
first and the outgoing pod's deregister then deletes that row. The
console's heartbeat only updates last_heartbeat -- heartbeat_service()
returns False when the row is missing and the caller discards it -- so
the registration is never recreated and the console stays invisible in
the registry for the life of the process.

Recreate orders shutdown strictly before startup. It is gated on
console.replicas == 1, since Recreate is meaningless above that and the
fixed service_id makes multiple console replicas overwrite each other
regardless.

The better fix is arguably in the application: have heartbeat_service()
re-register when its row has gone, which would make this unnecessary.
Happy to drop this commit in favour of that.

Note for existing deployments: switching strategy on a live Deployment
fails with `spec.strategy.rollingUpdate: Forbidden: may not be specified
when strategy type is 'Recreate'` because the stored object still
carries the defaulted rollingUpdate block. It needs a one-off
`kubectl patch` to remove that field. Fresh installs are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:42:22 -07:00
BlackMyrmidon e92c262ed8 Fix/docker compose fails to run wsl (#945)
* container start fails on WSL run entrypoint.sh due to permissions

* Update .gitignore
2026-08-02 13:35:31 -07:00
Patrick Buckley 4bd64fec75 docs(readme): serve the harness diagram from the LFS media endpoint
raw.githubusercontent.com returns the 131-byte LFS pointer for
lfs-tracked paths (.gitattributes tracks *.png), so the README image
rendered broken. media.githubusercontent.com serves the actual bytes
(verified 200 image/png).
2026-08-02 13:16:54 -07:00
Patrick Buckley 3960aeef88 docs(readme): lead the what-is-a-harness section with the diagram
- docs/diagrams/harness.png: cartoon rendering of the HYPOTHESIS.md tuple
  (256-color quantized, 803KB)
- README: image served via absolute raw URL so the PyPI page renders it;
  caption formula corrected to tau_H (the doc's notation) and the ill-typed
  rho(M_W(pi), E) composition shorthand dropped; formalism linked beside
  the primer
2026-08-02 13:13:54 -07:00
Patrick Buckley bb9684f505 fix(ui): block-copy dismissal listens on documentElement, not document
document-level mouseleave delivery on window exit is flaky in some
engines, stranding the floating button until the next in-page pointer
event; the <html> element receives the leave event reliably.
2026-08-02 06:15:40 -07:00
Patrick Buckley 729a02a833 feat(ui): copy-to-clipboard for messages and rendered blocks
Three idle-only affordances on every chat surface: a persistent copy
button in each assistant bubble's actions bar, a pointer-only floating
button over the hovered markdown block (fence, mermaid diagram, table),
and Enter on a focused block for keyboard users, with the outcome
flashed on the block itself.

Copy resolves to SOURCE, not rendered text.  The renderer stashes each
table's raw markdown in data-md-source at render time — span sentinels
restored in reverse mask order, footnote-definition bodies restored to
raw before their recursive render — and whole-message copy reads the
streaming pipeline's per-frame stash.  The clipboard transport falls
back to the legacy execCommand path for plain-HTTP LAN nodes, cloning
and restoring the user's selection and focus.

Outcomes surface button-local only: flash + title + one live-region
announcement through the shared makeAnnouncer factory (also adopted by
the interactive voice/tool announcers, whose lazily created regions
swallowed their first announcement).  Busy refusals answer with their
own message.  Coordinator retry and admin token-copy keep
zero-module-dependency degrade paths.
2026-08-02 06:15:40 -07:00
Patrick Buckley e526df95d0 fix(tool-search): discovery-failure records are per-user, rerank counts honest
Follow-up to #938; closes #941. The unavailable-server advisory fired for
users whose own pool was warm: _pool_discovery_error was keyed by server
name while pool connections are per-(user, server), so one account's
failed prime rendered its exception text into every user's search results.

- mcp_client: re-key _pool_discovery_error to (user_id, server_name).
  Written by the failing user's prime (single sanitize-and-cap pipeline
  shared with _set_error), cleared by that user's successful connect,
  retired with the grant on explicit disconnect / dead-grant convergence,
  and swept name-wide on registration lifecycle (removal, reconcile
  auth-type flips) via a snapshot-safe helper. Departed users' records
  are reaped by the eviction tick's orphan sweep — the single tick-side
  reaper; a live user's record survives its stub's eviction because the
  advisory has no mid-session re-record path. The eviction loop also
  starts on record write, so records written before any pool entry
  exists cannot outlive their users. Status reads scope to the
  requesting user, with an any-user view under the admin aggregate flag.
- tool_search: _status_reason treats discovery_error as an outage only
  when the requesting user's own status is not connected — with per-user
  records this is belt-and-braces, since a successful connect clears the
  user's record.
- session: the tool-search status snapshot scopes to the EFFECTIVE user
  (the acting participant on shared workstreams), matching the get_tools
  call that builds the search corpus, so an owner's pool state never
  renders into a non-owner's results.
- bm25: with a reranker attached, matches ranked past the recall pool
  trail in BM25 order (reorder mode), so tool_search's "top N of M"
  count no longer floors at the pool size; the exception fallback is
  mode-aware (filter mode keeps its pool bound, byte-for-byte).
2026-08-02 01:48:30 -07:00
metaclassing c4b2dd7135 feat(tool-search): surface MCP discovery failures & honest result counts (#938)
Tool discovery for a pool-backed (oauth_user/oauth_obo) MCP server that is
down or 5xx-ing was invisible: the server contributed zero tools to the
catalog, so tool_search returned "No matching tools found" —
indistinguishable from a genuine no-match — and matches past max_results
were silently dropped with no signal.

- tool_search: search() ranks the whole deferred corpus and records the
  pre-slice match count so format_search_results can report honest
  truncation ("top N of M"). An optional status_provider lets results name
  servers that are actually failing (open circuit breaker, recorded error,
  recorded discovery failure) instead of masquerading as "no such tool".
  Un-primed servers are deliberately not flagged, and a provider that
  raises never breaks search.
- mcp_client: the previously swallowed pool prime/connect discovery
  failure is recorded per server (single-line, bounded), cleared on the
  next successful pool connect, on removal, and on reconcile-observed pool
  removal or auth-type flips; exposed via get_server_status
  as "discovery_error".
- session: wires get_all_server_status(user_id) into both
  ToolSearchManager constructions as a lazily-called status provider.
2026-08-01 21:52:48 -07:00
renovate[bot] deffd57ab9 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.12.1 2026-08-01 21:39:07 -07:00
Patrick Buckley 166b46cda4 chore: bump version to 1.8.0a5 2026-07-29 22:49:25 -07:00
Patrick Buckley 57a9041941 fix(coordinator): the interjection handoff cannot lose the message, and the fact block is bounded
Review fold-in before push, twelve findings, two of them majors.

The handoff popped the interjection queue destructively and handed
the text to a send with a non-delivering refusal (the budget latch)
and a preamble that can raise before the user turn is appended — a
failure destroyed the user's words with a log line, after the charged
wake nudges were already cleared. Now: the budget latch is checked
before the pop (the message stays queued for a send with a human in
front of it, and the wake drain still runs so the worker's exit
converges); the pop returns the raw items and any non-cancel escape
restores them verbatim — ids and priorities intact — before the
failure surfaces; a cancel deliberately does not restore, because the
Stop supersedes the queued words. Content-free items (a bare priority
marker) are skipped at the shared renderer, so a lone '!!!' no longer
buys a content-free turn at the cost of both nudges.

The per-child fact block takes the roster formatter's bounds: fact
lines cap at the display cap with a counts-only overflow line, and
the wait slot keeps its larger handle cap — the body is a persistent
system turn replayed on every request, and the block previously grew
without bound as finished-but-unclosed children accumulated. The two
fact sentences and the overflow line are named template constants,
and every test assertion anchors on them; the children projection
takes the same drop-never-mangle alteration check as the open-row
fields.

Eval world seeding: node metadata is JSON-encoded exactly as
production writers store it (a raw string never matched a filtered
list_nodes lookup), the stub client pins its heartbeat window open so
a static world cannot go hollow mid-run, and the world-shape
refusals' field branches gain their own tests. Comment accuracy and
paragraph wrapping fixed at the sites the review named.
2026-07-29 22:11:38 -07:00
Patrick Buckley 2519dc9dcf chore(coordinator): comments describe the design, not the process that produced it
Peer-review cleanliness pass over the branch's production comments and
docstrings. Dated rulings lose their dates and attribution wrappers —
the rule is the content. Measurement-process references (sweep rounds,
model names, cell names, rates, arm names, a composition caveat that
had since been satisfied) become timeless design statements: what a
property buys and what falsifies it, not which run established it.

Two real staleness bugs found by the pass: the body's properties
comment still said the escape branches come first and the done branch
is last — both false since the branch reorder — and now states the
shipped order with its trade condition.
2026-07-29 22:11:38 -07:00
Patrick Buckley 2ff7c61051 feat(coordinator): idle nudges deliver only on the idle wake, and a queued interjection owns the seam
The idle nudges enqueued on the any channel, which every drain seam
serves — a deferred wake left them deliverable at the start of a real
user send or mid-turn at a tool batch, describing an idle moment that
no longer existed. They move to a new wake channel: wake-eligible,
invisible to USER_DRAIN, TOOL_DRAIN, and the quiet ride-along. A user
cancel drops pending wake entries rather than demoting them — the
quiet demote's whole value is later seam delivery, exactly what this
class may never have. Dropping a charged entry is the accepted
fail-closed cost; liveness surviving Stop means the next idle event
fires fresh, not that a queued entry re-wakes the workstream.

A queued user interjection owns the idle seam: at wake delivery, a
non-empty interjection queue drops the wake-channel entries and the
interjection runs as a genuine user send in their place — no wake
tag, so the caps reset as for any real send and the next genuine idle
re-derives both nudges over fresh reads. The check lives in the wake
worker (which owns the slot and can dispatch a full send), not the
watcher's state-transition thread, where skipping would strand the
message. Measured before building: send('') with queued messages
appends an empty user turn and delivers the interjection one
assistant turn late, so the handoff pops first and sends the popped
text — one rendering shared with the flush seams.

External events are not idle nudges: any-channel entries still arm
the wake alone, and with an interjection waiting they ride the
genuine turn's drain seam — both deliver, only the idle nudges drop.
A failed wake send drops wake entries alongside user ones; externals
requeue quiet as before.
2026-07-29 22:11:38 -07:00
Patrick Buckley 074b9e02e2 feat(eval): cells seed the tool-visible world through production writers
Every surface the model can observe must agree about the world's age
and contents. The C1 confirm at n=25 measured models sweeping memory,
skills, and list_nodes, finding voids that contradicted a transcript
full of referents, and spawning read-only investigators to resolve
the contradiction — the forbidden rate was measuring the fixture's
hollow tool-world, not dispatch discipline.

A cell's world block seeds structured memory rows through the same
upsert the memory tool's save action commits (names normalize exactly
as model-saved rows do), and node rows through the service registry
plus node metadata — the two reads list_nodes intersects, so a seeded
node is live inside the heartbeat window by construction. A seed
failure raises; a malformed world block is refused at config time
before the canary, with its own trip cell in the reachability guard.

The approval-stop cell gains the first world: two process-fact memory
rows (no coaching — the reservation lives in the transcript only) and
one live node.
2026-07-29 22:11:38 -07:00
Patrick Buckley 76c5519b44 feat(coordinator): done branch leads the tasks body; eval worlds survive honest inspection
Three fixes, one per causal mechanism the round-12 baseline exposed.

The done branch moves ahead of the escalate branch. The escalate-first
order rested on a harm argument — guessing on an operator decision
outranks redone bookkeeping, so the escape hatch should be salient —
and the baseline measured its cost: 7 of 10 finished-unmarked runs
reached for the body's first populated call and escalated visibly
finished work, one mode, no tail. The next round measures the reversal
both ways: if the legit-stop cells' forbidden rate rises, the harm
argument was right and the order flips back (the pin says so in
place).

The approval-stop cell's transcript anchors its world — named repo,
named migration, named artifacts. Its forbidden runs were not sign-off
defiance: the model swept empty discovery surfaces, found a void, and
spawned explore-the-project children, so the cell was measuring
hollow-world exploration rather than dispatch discipline.

The co-delivery cell's running child gains an observations-only
progress note beside its assignment. A bare-assignment static child
cannot survive sustained honest interaction — wait times out, inspect
shows nothing, and after patience cycles the model correctly diagnoses
a hung child and cancels/respawns, which the forbid list scored as
redo. The note makes the child look alive without looking finished.
2026-07-29 22:11:38 -07:00
Patrick Buckley 48d6b2f84b feat(coordinator): the nudge bodies state observed facts, never hedges
The idle-children header drops its opening idleness claim: a queued
entry delivers at whichever seam arrives next, and the drain
predicate re-verifies that children are active — never that the
coordinator is still idle — so the body now opens with the one fact
the delivery just verified.

The tasks body replaces its hedged children sentence with one
observed-fact line per child. The old sentence hedged states the
producer's read had just returned and invented activity for an idle
coordinator; the producer now threads (ws_id, state) pairs through,
and the formatter renders a running child as running (check before
redoing what it owns) and a stopped one as stopped, with the
tool-behaviour fact that wait_for_workstream returns immediately for
it. The line asserts nothing about results: no read observes whether
a child produced anything, and the immediate wait is the whole
protection — checking is cheap and finds whatever is there. Fact
lines are formatter-built beside the counts opener, so no tail
override can reach them; the formatter's old indeterminate-read hedge
branch is deleted (a failed read renders no body at all), and the
open-row status takes the same alteration check as the id.

Both bodies hand the model full workstream ids: the resolver refuses
truncated ids by design, so the roster's 8-char prefixes were not
handles — a model copying a bullet issued a call the resolver
rejects. Display prefixing stays on the operator card, derived from
the full id in the metadata.

Eval alignment: fixture child ids become production-shaped 32-hex (a
prefix looked like a different id entirely and the old shape only
resolved through the legacy branch); a body-override sweep refuses
cells without a live child at config time, keyed on the formatter's
own childless condition, so candidate text can never be measured over
a world production cannot produce.
2026-07-29 22:11:38 -07:00
Patrick Buckley 8874dcaa69 fix(optimizer): stop pinning sampling knobs on the wire
Same defect as the eval CLI: temperature defaulted to 0.7 and
reasoning effort to a code-chosen token, where the wire should omit
both and let the alias / stored setting / serving default apply. The
effort flag also loses its CLI vocabulary — the chat template is the
sole authority on valid tokens.
2026-07-29 22:11:38 -07:00
Patrick Buckley c08784192a fix(eval): forward reasoning effort verbatim, no CLI vocabulary
The flag carried choices=[low, medium, high] — a second validity
authority beside the chat template, and one that rejects tokens some
models actually define (a template that knows only high and max was
unreachable through it, while the old medium default sent a token
that same template never defined). The template is the sole
authority; the flag forwards whatever the operator typed.
2026-07-29 22:11:38 -07:00
Patrick Buckley 1bc2c39ca8 fix(eval): stop pinning temperature and reasoning effort on the wire
The eval CLI defaulted temperature to 0.7 and reasoning effort to
medium, so every sweep sent code-chosen sampling knobs the house
assignment scheme forbids — the wire should omit the fields and let
the alias / stored setting / serving default apply, as production
does. Both flags now default to unset and the harnesses plumb None
through to model_turn, whose provider layer already omits absent
knobs. Absolute numbers from earlier sweeps were collected under the
pinned values; contrasts were at least uniform under the same pin.
2026-07-29 22:11:38 -07:00
Patrick Buckley 33c82962a2 fix(channels): suppress mention resolution and escape untrusted fields
User- and model-authored text (task titles, approval headers, command
previews, judge output, error fragments, notification bodies) reaches
both channel integrations verbatim, and nothing at the channel
boundary neutralised it.

The Discord client now carries a client-level allowed-mentions-none
default, which every message create inherits — plain sends, edits,
and embeds — so broadcast and mention syntax in untrusted text cannot
resolve, without mutating the text itself.

The Slack adapter escapes each untrusted field into mrkdwn entities
at its interpolation site — never the assembled message, so
deliberately bot-authored markup like the session-opener mention
survives. The policy-deny feedback returned to the server stays
verbatim; only the rendered notice escapes.

Storage and the shared formatter stay channel-neutral and verbatim:
projection happens per audience at the render boundary.
2026-07-29 22:11:38 -07:00
Patrick Buckley b8dd5041c4 fix(session): force-cancel runs the abandon machinery before emitting idle
The force branch cleared worker ownership and emitted idle from the
route thread, while the abandon latch and the queue demote ran only
in the stuck worker's own exception handler — a thread force-cancel
abandons precisely because it is not making progress. Subscribers on
the IDLE fan-out therefore saw an operator-forced idle with the latch
unset: the idle observer's operator-Stop gate did not suppress
advice, and wake-eligible entries survived un-demoted, so a nudge
wake could resume a workstream seconds after the operator forced it
to stop. The route now runs the session's abandon machinery first;
the abandoned thread re-running it at its eventual death is
idempotent.
2026-07-29 22:11:38 -07:00
Patrick Buckley 457750e20c fix(eval): close the parallel lane's per-item client
The subprocess worker built a fresh client per work item and never
closed it. Pool workers are reused across items, so each one
accumulated a live transport per item for the life of the sweep.
2026-07-29 22:11:38 -07:00
Patrick Buckley 176f6a9631 chore(sdk): regenerate the console spec for the tasks schema
Picks up the needs_user status description and the new note property
from console_schemas.py. Spec-only, no API behavior change.

The server spec is knowingly left stale here: the generator writes
both files unconditionally, and its drift belongs to the change that
introduced it rather than to this one.
2026-07-29 22:11:38 -07:00
Patrick Buckley 556ec793a0 feat(eval): behavioral eval for the coordinator idle nudges
turnstone-eval --nudges runs seeded coordinator states against
stimulus arms and scores state-first: cells seed a real task envelope
through the production tasks_add path into a per-run temp DB, the
model's tasks calls really execute, and ground truth is the final
envelope plus a forbidden-action list — robust to action-path
variation, and never tool_choice-forced. Arms render through the
production formatters so the wire carries exactly what production
sends; a body-override lane exists for tuning A/B only and skips the
ablation arm, whose reading only means anything against the body that
ships.

Children are seeded with transcripts — an assignment message, plus a
completion-with-findings for idle children — so honest inspection
finds a world rather than an empty room, and a collect-vs-redo cell
measures the real question. Cell authoring is validated up front
(including refusing a parked task seeded beside an open one, a state
production never sends a body for); mutating calls are scored by what
landed, not by what was attempted.

Instrument health is measured, not assumed: a canary probes tool-call
parsing before and after the sweep, a mid-sweep tripwire aborts a
sweep whose parser dies rather than printing a red grid, and
empty-log runs are labelled harness: so they can never score against
the model.

The coordinator idle-observer test file lands in this commit rather
than the feature commit: its parity guard imports the eval scenarios
to pin the production formatters and the eval wire to one rendering.
2026-07-29 22:11:38 -07:00
Patrick Buckley a53bf30428 feat(coordinator): carry task and child handles across a compaction
The tasks tool returns an id beside its title, and spawn returns a
child's id; that pairing lives only in the transcript, and compaction
replaces the transcript. A coordinator that loses it cannot update its
own tasks or collect a finished child's results — and it is exactly
the coordinator most likely to be sitting idle holding unfinished
work. The idle nudge now carries storage-derived ids for the same
reason, and this is the other half: the nudge supplies the
authoritative set, this preserves what each one means.

The harness writes the block itself rather than asking the summariser
to preserve ids. Both are available to it — the reads are same-process
storage on the thread already running the compaction — so asking the
model to transcribe what the controller is holding would be a
shortfall in the lowering, and would make every id fallible to no
purpose. Neither compactor prompt changes at all, and a test pins that
they stay identical across kinds, so a future prose section has to
revisit this trade rather than stack on top of it.

Interactive sessions have no task envelope and no children, so they
take no reads and render nothing — a gate on the kind, not a section
that renders empty. Their compaction is unchanged by construction.

The block joins the existing carries, which is where the real hazard
was: it lands in the same post-compaction prompt as the wind-down
spill and the continuation ask, so it is counted as a third carry and
rendered against that shared budget, with the count and the render
reading one answer. Truncation drops whole rows and names what it
dropped; half an id is a call that cannot resolve wearing the costume
of one that can. Tasks are served first, with room reserved so a long
list cannot starve the children.

Titles keep their angle brackets here — unlike the nudge bodies, which
delete them because they interpolate into a system turn where a
tag-shaped run steers. This is the assistant channel, the titles are
the coordinator's own, and they already reach this same model verbatim
through its own list results; deleting brackets would only invert the
constraints it is working to. The control class is still stripped, so
a newline in a title cannot forge a sibling row.

A failed read costs the block, never the history: trading a whole
history swap for a side read would be the worse failure by far.
2026-07-29 22:11:38 -07:00
Patrick Buckley 0d52b63b50 feat(coordinator): nudge a coordinator that goes idle holding unfinished work
Two nudge classes can fire from one IDLE event, tasks first, each
asserting only its own domain.

idle_tasks (advice) fires when open (pending/in_progress) tasks
exist. The body is a counts opener, the open task ids with statuses,
and typed branches that each end in a runnable tasks(...) or
wait_for_workstream(...) call populated with real server-minted ids.
Everything it says about children is governed by one observed fact:
live children present adds the caveat sentence and the
blocked-on-a-child branch; affirmatively none says nothing about
children at all. Any needs_user row parks the class entirely — at the
fire gate and the drain predicate — because with no task graph an
open task may be gated on a parked one's unanswered question; the
operator's answer is the re-arm. Gated on memory.nudges and on the
persona actually exposing the tasks tool; carries the per-class
cooldown as well as the per-bracket cap. The tasks tool itself gains
the needs_user status and a note field — the typed escalation the
body's branches point at.

idle_children (liveness) fires when children are in a live state —
the wake that lets an idle coordinator collect a finished child's
results. The body is a roster of workstream id prefixes and states,
never names: a child's name is model-authored text and does not enter
a system turn. Cap-only and cooldown-free by design, not gated on
memory.nudges, and it survives an operator Stop.

Fail-closed, event-wide: if any storage read fails while the observer
handles an IDLE event, neither nudge is queued and neither cap is
charged. Both paths run as side-effect-free plans; the commit tail is
storage-free, so no read can fail past the veto point; both drain
predicates drop on a failed read. A path's own fault (a generic
raise) still costs only that path's fire, so one class's bug cannot
strand the other.

Task text is stored verbatim and projected per audience at render:
the model-facing projection deletes angle brackets, the operator
projection keeps them, and both strip newlines and bidi/zero-width
runs. Idle cards render what the model was told, formatted for the
operator, never augmented with content the model did not receive.
2026-07-29 22:11:38 -07:00
renovate[bot] 15ec735354 chore(deps): update astral-sh/setup-uv action to v9 2026-07-27 03:52:31 -07:00
renovate[bot] a184494fe7 chore(deps): lock file maintenance 2026-07-27 00:45:01 -07:00
renovate[bot] ac936214d7 chore(deps): update github actions 2026-07-27 00:24:21 -07:00
Patrick Buckley 96834496c4 feat(anthropic): onboard claude-opus-5
The capabilities row is a copy of claude-opus-4-8 — 1M context, 128K output,
adaptive thinking, the full low..max effort ladder, mid-conversation system
messages. Two of the model's documented breaking changes are unreachable from
this lane and stay that way only while thinking_mode is "adaptive": thinking is
on by default when the param is omitted, and disabling it is a 400 at effort
xhigh or max. We never omit it and never emit "disabled", so both are recorded
at the row rather than defended against.

The third needed work. A safety classifier can decline with
stop_reason="refusal" on a successful HTTP 200, with content either empty or
partial, and an unmapped value fell through _normalize_finish_reason as a
literal string. The drain gate only raises on an ABSENT finish reason, so a
declined turn landed as a complete result with nothing to notice it: the
interactive lane matched neither of its two warn arms and stayed silent, and a
sub-agent handed the declined partial up to its parent as though it were
finished synthesis. Normalizing onto content_filter routes the decline into the
arms both lanes already have for that state — the operator gets the warning,
and the sub-agent stops rather than passing the fragment on.

The raw stop reason is logged where it is still in hand: normalization is lossy
and a classifier decline is otherwise indistinguishable from an ordinary
content filter. The gate is on the RAW value rather than
(normalized != raw), which is true for end_turn and tool_use as well and would
fire on every turn in every lane.

The anthropic floor moves to 0.117 to track the release current at onboarding.
The model needs no new SDK surface — ids are opaque strings and "refusal" has
been in the StopReason literal since ~0.95 — so this is hygiene; raise it again
when adopting fast mode, server-side fallbacks, advisor, or mid-conversation
tool changes, which do need newer typed params.
2026-07-25 01:12:04 -07:00
Patrick Buckley 04c29c8ef5 test(authz): stop the retract test racing the drain's claim window
test_persistently_crashing_entry_is_never_dropped_and_retract_frees_drain
fired its DELETE off the attempt counter, which increments as the FIRST
statement of the attempt. The counter therefore crossed 2 while the drain
still held the entry CLAIMED — popped off _pending_sends, dispatch in
flight. Retract only scans that list, and correctly answers not_found for a
claimed entry, so the request was racing the crash path's re-insert and the
assertion saw not_found instead of removed.

Both sides of that race are the same order of magnitude, which is why it
read as machine-specific rather than simply broken: the re-insert lands
after an intervening log.exception, roughly 5ms under pytest's capture
handlers against 0.05ms with none installed, and wait_until polls at 5ms.
A fast idle machine loses the race; a slower or busier one wins it.

Wait for the state the test is actually about — the entry back on the list,
mid-crash-loop and retractable — rather than for the counter. That is also
what the docstring already claims is under test.

The contract still bites: dropping the entry on the crash path fails the
new wait, and removing both retracted-purge sites leaves the drain spinning
and fails teardown.
2026-07-25 01:07:30 -07:00
Patrick Buckley 6f194d338f fix(console): restore back-to-console from a proxied node view
The console proxies a node's web UI at /node/{id}/ and injects a shim into
the page it fetches upstream. The only way back was a node-picker menu the
shim built into #ui-header — an element the L-shell renovation (cc508cf4,
shipped v1.6.0) removed from the server UI. buildPicker() has returned on
its first line ever since, so every supported node version has served a
proxied page with no in-UI way back; the browser back button or a
hand-edited URL were the only exits.

The shim now repoints the rail brand (.rail-brand .brand-home) at "/" and
relabels it, so the element users already read as "go home" goes home. It
captures at the document rather than on the button: shell.js binds a bubble
listener to that same element, and stopPropagation() keeps showHome() from
firing as well. The node's own dashboard stays reachable as the
non-closable first tab.

The dead picker goes with it — its JS, the CSS constant that styled only
its elements, the el() helper, and the NODE_ID_PLACEHOLDER substitution
whose only reader it was. It could only ever have run for nodes at or below
v1.5.x, which are not a supported configuration.

The failure mode here is silent by construction: the shim reaches across a
process boundary to select classes another file emits, and fails soft when
they stop matching. Nothing failed when #ui-header disappeared. So the
coupling is now pinned from both ends.

- tests/test_shell_js.py asserts both halves. The class names are derived
  from the shim's own querySelector calls, so a newly selected class is
  covered without editing the guard, and a vacuity floor keeps it from
  going green if the selectors are removed entirely. The containment edges
  are pinned separately, deriving shell.js's local variable names from
  source: renaming a local stays green, re-parenting .brand-home out of
  .rail-brand does not.

- tests/test_console.py executes the shim under node against a two-walk DOM
  dispatcher — capture walk, then bubble walk, phase-filtered at every node
  including the target. Modelling the real rule means the test accepts any
  correct wiring rather than only the one that shipped.

- The injection test drives the real proxy_index against the real node
  index. That also pins the bare <body> the literal replace() depends on;
  an attribute there would silently drop the entire shim, prefix rewriting
  included.

- scripts/livepass.py gains a proxybrand harness for manual verification:
  an iframe host over the real shell.js and the real shim, reading the
  frame's post-navigation location from the surviving top page. The shim is
  read out of the source by text rather than imported, since scripts/ has
  no sys.path guard and an import resolves to site-packages.
2026-07-25 00:50:29 -07:00
Patrick Buckley 4007fab855 fix(#900): close two vacuity holes the round-2 scenarios left open
Round-3 review, unprimed. Two of its majors were the new scenarios
asserting things they did not prove — the false-detector class this
campaign keeps returning to.

E8 never checked that the held /history was still OUTSTANDING when the
redial completed. The disconnect/send/wait_turn/redial sequence is
unbounded (wait_turn alone allows 45s), so on a slow box the payload
resolves while evtSource is still null, the PRESENCE term declines it, and
the run stamps dupes1-healed1 without ever evaluating the generation term.
It now fails loudly with the counter values instead. E7 gained the same
positive proof its siblings already carried: sse_opens == 0 only means
"nothing connected in 8s", which is not the same as "the held load
settled and its .finally chose not to reconnect".

E5's stated control was simply wrong, in three places. A hide nulls
evtSource, and connectSSE early-returns while hidden, so the scenario
cannot produce the non-null-but-not-OPEN source that readyState === OPEN
exists for — it exercises the presence term only. The earlier control
removed both terms at once, which is what disguised it. The readyState
half is covered by reasoning plus coord parity, and its correctness twin
IS covered through the render-time gate by E6/E8; that scope is now
written down rather than overclaimed. Coord's G5 has the same shape.

The retry floor becomes a shared export beside its jitter: four sites must
move together (both clients' arms, both non-occurrence windows) and it was
the only one of them with no single source of truth. Interactive's use of
the expression had no pin at all — reverting it to a bare 2000 would have
broken cross-client parity with the suite green. Coord's re-anchor still
raised ValueError rather than failing on a named assertion, and its first
replacement used a fixed window that truncated mid-expression.
2026-07-24 18:15:03 -07:00
Patrick Buckley 7ed5d90a98 test(e2e): E8 observes the double render; honest non-vacuity for E6 (#900 r2)
Until now nothing in this harness could see the artefact the campaign
prevents. Every scenario counts .msg.user rows, and user rows never
travel on the SSE stream — a /send emits none, only /history replay
paints them — so a duplicated assistant bubble was invisible to all of
them. E8 counts a sentinel's occurrences in the transcript text instead,
which is structure-agnostic across duplicate bubbles and tool blocks.

The window it drives is the one readyState cannot see: the retry fires
with the transport OPEN, its /history is held, and inside that await the
transport drops and re-establishes. readyState reads OPEN afterwards
exactly as before. The redial is a real disconnect+connect rather than a
visibility change on purpose — a hide leaves evtSource null, which the
presence term already decides, so a hide-based control would pass for the
wrong reason. Control: stripping the generation term stamps dupes2.

E8's expectation needed correcting once: unlike E6, the stream is live at
flush time here, so the declined render's queued settle fires the
transport-free backstop and the pane converges in one settle. That is
correct behaviour, so the heal is asserted as a convergence leg — a "no
duplicates" verdict must not be earnable by rendering nothing.

E6 gains the non-vacuity it was missing: history_requests counts on
ARRIVAL, before the hold and before any status is chosen, so "the gate
declined a good payload" and "there was no good payload" stamped
identical observables. history_ok — incremented only when the production
route answers 200 — closes that, including the production-side-failure
hole an injected-fail budget cannot see.

Both hidden-window detectors widened for the additive jitter: sized on
the 2000 floor alone they would have closed before a top-of-range firing
and reported hidden0 for the wrong reason. delay_history(0) comments
corrected — it cannot release an in-flight hold.
2026-07-24 18:15:03 -07:00
Patrick Buckley 7daf3b782d fix(#900): stream generation closes the reconnect-inside-the-await render; jitter both retries
Round-2 review. The render-time cursor-safety gate was point-in-time: a
transport that dropped AND finished re-establishing inside the /history
await reads back OPEN and is indistinguishable from one that never moved.
It is not — the redial re-presented the frozen cursor, the server answered
replay_ok, and the quiesce buffered that slice, so the render commits rows
the flush then repaints on top. Object identity cannot see it either,
since a native reconnect reuses the same EventSource; only a counter can.

_connectEpoch is bumped in onopen and nowhere else. Native auto-reconnect
calls neither connectSSE nor disconnectSSE, so those two are blind to the
exact case this exists for; connectSSE would also false-bump on its
document.hidden early return, which establishes no stream; and a closed
source can never fire a late open. Captured at dispatch, required
unchanged before a seedless render commits.

This is original-strata residual, not a regression this branch introduced:
before #900 the render was ungated entirely. The branch closed the
fire-time half and the still-down cases; these are the drop-and-recover
ones that were always open.

Two rulings written in at the gate, since neither is closed: a
fresh/truncated reconnect inside the await declines a render that would
have been safe (one wasted /history, self-healing via the flushed
synthetic state_change), and a refetch dispatched between onopen and the
replay slice arriving still renders past the frozen cursor — replay_ok
emits no end-of-replay marker, so no client-side signal exists (#903).
Coord's half of the same gate is #904; its exposure is a race rather than
this determinism, so it is not ported blind.

Also corrected: the claim that the idle-edge backstop's stream is live by
construction. It isn't — handleEvent also runs from the quiesce flush, so
a queued idle edge reaches the backstop with the transport down. The
render-time gate is what covers it. The clear_ui retry gains additive
jitter in BOTH clients from one shared constant: a declined render now
leaves the latch set, so a successful fetch can arm the retry, and the
decline trigger is herd-shaped. Kept small deliberately — the spread works
against #884's single-flight, which coalesces a lockstep herd.

test_coordinator_page.py anchored the fire guard on a literal `}, 2000);`
and on exact indentation; both would have ERRORED rather than failed once
the delay became an expression.
2026-07-24 18:15:03 -07:00
Patrick Buckley 539b91d30b test(e2e): E7 destroy-invalidation — first browser coverage of the factory teardown (#900)
Every other interactive scenario mounts the Pane class directly, so the
factory closure that owns destroy() had no browser coverage at all — and
that is exactly where #900's largest hole lived. E7 mounts through
createInteractivePane (scenario-scoped: the factory owns its own
connect/recover-beat lifecycle, so switching the others would change what
they test), holds the first /history at the fault layer, destroys the
controller mid-flight, and lets the load resolve into the void.

Detector is a fault-layer non-occurrence: events_requests still 0, pane
detached, _visHandler null behind it. Without the bump it stamps
sse1-vis0 — the .finally reopens an EventSource on the detached pane and
re-registers the document-level visibilitychange listener destroy just
removed, which is the leak, now observed rather than traced.
2026-07-24 18:15:03 -07:00
Patrick Buckley 0a31709aed test(e2e): script E6's heal turn explicitly
An exhausted script queue still settles — into the error arm, which the
idle-edge backstop also consumes — so the heal leg would have passed for
a reason the scenario does not name. Queue the fourth turn like E4/E5 and
assert its sentinel.
2026-07-24 18:15:03 -07:00
Patrick Buckley c0261c907c test(e2e): E6 await-window-gate — the render-time half the fire guard can't see (#900)
E5's retry never fetches, so it cannot exercise the render-time check.
E6 reaches it the only way available: the retry fires on a live stream,
its /history is held at the fault layer, and a close-on-hide drops the
transport while the payload is in flight.

The detector is the stale-but-real PRE-rewind transcript surviving a
RESOLVED fetch — three user rows with the latch still set. The latch leg
is what makes it honest: replayHistory is the latch's only clear site, so
a held latch proves no render ran rather than inferring it from row
counts alone.
2026-07-24 18:15:03 -07:00
Patrick Buckley 8effe656bb test(e2e): E5 hidden-retry — the fire guard's non-occurrence detector (#900)
The interactive mirror of coord's G5. A close-on-hide inside the retry's
2s arm window is the reachable way to make it fire against a down
transport, and the detector is a NON-occurrence counted at the fault
layer: history_requests must be unchanged across the hidden window.
Remove the OPEN term and the hidden fetch lands, stamping hidden1.

The scenario also pins the two rulings the guard leans on: the latch
must still be SET after the skip (a skipped retry heals nothing), and
the show edge alone must not heal it — a replay_ok reconnect carries no
synthetic state_change, so the repair rides a plain send's organic
settle into the transport-free backstop, which is why exactly one new
SSE open spans show + heal.
2026-07-24 18:15:03 -07:00
Patrick Buckley a8ce660619 fix(#900): destroy invalidates in-flight loads; cursor-safety gates the seedless render
Backport set from the #894 coordinator campaign, verified against
interactive.js source before fixing.

destroy() bumped no load token, which made it the WEAKER of the two
terminal paths (giveUp already bumped). Three escapes followed, all
reachable on the shell's onClose path: _loadHistoryThenConnect's
.finally reopened an EventSource on the detached pane and re-registered
the document-level visibilitychange listener destroy had just removed —
whose onerror then re-armed the host recover beat indefinitely, because
it gives up only on `dead`, which destroy never sets; a settling
_refetchHistory passed its supersession check and replayHistory'd into
detached DOM; and the clear_ui .then re-armed _staleRetryTimer after
destroy's own cancel. One bump at the terminal seam closes all three,
since the token is already the chokepoint every post-await consumer
reads. giveUp gains the matching timer cancel — inert is not dead.

The clear_ui retry could also fire against a DOWN transport:
disconnectSSE deliberately keeps it armed, so a hidden tab, a degraded
cooldown or a native redial holds the fire while _lastEventId is frozen.
A seedless refetch then paints rows the cursor still sits below and the
next connect's replay_ok paints them again (content and tool rows carry
no id dedup). The fire guard now requires an OPEN stream, and
_refetchHistory gains the render-time half at the chokepoint, covering
the await window a fire-time check cannot. Seeded loads are exempt by
construction — their caller disconnects first and readopts the cursor.
Deliberate trade, already ruled: the latch survives a skip, so
rewind/edit stay closed until the idle-edge backstop heals at the next
settle.

Two of the four filed findings are declined with the ruling written in
at the site, so an unprimed round re-derives rather than re-files them:
the same-token overlap is unreachable here (the replay quiesce
serializes what coord's refetchSeq stamp had to order, because coord has
no quiesce), and the joined-flight window is closed for both clients by
the shared make_history_handler's generation-keyed flight.
2026-07-24 18:15:03 -07:00
Patrick Buckley d8d026394f fix(#894): cold flights key on None; typed generation access; abort-Set producer pins
Review round 10 (1 minor bug; 2 major + 2 small quality — the majors
both pins-that-cannot-fail).

- The flight key's cold fallback was the literal 0, which collides
  with a live session's generation 0: an eviction/close landing inside
  a held flight's window let a post-truncation request rejoin a
  generation-0 pre-truncation flight.  Cold/detached workstreams now
  key on None (rewinds need a live session, so two cold flights are
  always mutually safe; a rehydrated session restarting at 0 can never
  share the manager slot with its evicted predecessor — documented
  at-site).  The read is TYPED (live_session.session._history_generation)
  so mypy carries the shape a getattr chain hid — and the typed access
  immediately surfaced an unfaithful SimpleNamespace mock in the
  reasoning-rehydration tests (no .session attr), now made faithful.
- Abort-Set producer pins: histCtrls.add exactly once and BEFORE the
  await, delete exactly once and in the finally — without them the
  destroy() consumer sweep was satisfiable by an always-empty Set.
- _make_session gains ws_id; the generation producer pin uses it.
- _coord_stick_latch: G2/G5's inline single-failure prologues RULED
  deliberate at-site (their baselines/phase timings interleave into
  the prologue; a per-divergence flag would obscure the choreography).
- Stray trailing whitespace stripped.

250 pins green; G2/G5/G7 re-run READY.
2026-07-24 15:04:31 -07:00
Patrick Buckley 60f6dc07a2 fix(#894): drop the unreachable epoch guard; abort-Set; bump-after-delete; producer pins
Review round 9 (4 minor bug, 4 quality, 1 perf nit; security zero).

- The r8 clearUiEpoch guard was UNREACHABLE (r9 bug find): clear_ui
  always dispatches immediately after bumping, so a stale-epoch
  dispatch is also a stale-seq dispatch and the currency gate discards
  it before it can paint or clear — the client half of the joined-
  flight fix was already carried by seq, and the server generation key
  is the sole load-bearing layer.  Machinery removed (decl, bump,
  capture, conditional clear, section-9 pins); the latch-clear comment
  now states the two-layer accounting.
- destroy()'s abort handle becomes a Set: a newest-wins single slot,
  nulled by the newer dispatch's finally, left an OLDER overlapping
  fetch unabortable — the destroyed closure pinned for the bound's
  remainder.  Pinned.
- _history_generation now bumps AFTER delete_messages_after: flights
  rebuild from storage, so old-generation-reads-post-delete is the
  harmless spuriously-fresh direction while new-generation-reads-
  pre-delete would be wrongly joinable; the count/floor error paths
  correctly leave it unbumped.  Two-arm producer pin in
  test_rewind_retry (persisted-rows bump on rewind AND retry;
  in-memory-only error path must NOT bump) — the flight test's mock
  can no longer mask a deleted bump.
- The harness load_calls increment takes a lock (to_thread workers
  genuinely overlap under delay_load; a lost update false-fails G7).
- G7's viewer B is now a background authenticated GET (a raw request
  enters load_messages identically; the second browser bought no
  proof); stale two-tuple key comments and the coalescing matrix line
  updated; the _send_in_page enumeration dropped for prose.

250 pins green; G1/G6/G7 re-run READY.
2026-07-24 15:04:31 -07:00
Patrick Buckley b85f792925 test(e2e): G7 joined-flight detector at the flight layer; fix the generation read path it caught (#894 r8)
G7: two browsers on one ws; delay_load parks B's pre-rewind /history
flight open INSIDE load_messages — the flight layer.  (A first cut
held via delay_history, which sleeps in the FAULT layer before the
route: flights never overlapped there and the 'negative control'
passed vacuously — a false detector, caught and rebuilt.  The knob
also sleeps AFTER the load so a parked flight holds the rows it
actually read: its transaction point.)  A rewinds mid-hold; the miss
proof is load_calls growing TWO (a joined request never enters
load_messages — the e2e twin of the unit test's proof) plus A
rendering the post-rewind single row.

The rebuilt detector immediately caught a real bug in the server fix:
mgr.get returns the Workstream WRAPPER, and the route's direct getattr
for _history_generation silently defaulted to 0 forever — joining
stayed enabled while the unit test's mock (attr on the wrong object)
masked the shape.  The route now reads ws.session, and the mock pins
the nested shape so a wrong-object read can never pass again.

Negative control (flight key reverted to (ws_id, limit)): stamps
FAILED-loads1-rows3 — A joins the pre-rewind flight and paints three
stale rows as fresh truth.  Fixed: READY-posts1-loads2-rows1.
2026-07-24 15:04:31 -07:00
Patrick Buckley bc60646ff9 fix(#894): fold the truncation generation into the /history flight key
The r8 joined-flight window, server half (Patrick-approved scope
expansion): the #884 single-flight key was (ws_id, limit), so a
/history dispatched AFTER a rewind/retry could join a flight whose
load_messages ran BEFORE the truncation committed — the joined
pre-rewind payload reads as fresh truth client-side (the client's
dispatch stamp is current; the staleness is the flight's transaction
point, visible only server-side) and reopened the over-rewind window
through the server seam.  Reachable single-user (rewind clicked during
a truncated-resync fetch) and multi-viewer (any concurrent pane's
/history).

ChatSession gains _history_generation, bumped in _persist_truncation —
the shared rewind/retry chokepoint — BEFORE the storage write (the
in-memory tail is already trimmed by both callers; a spuriously fresh
flight is harmless, a wrongly-joined one is not).  The flight key
becomes (ws_id, limit, generation): post-truncation dispatches can
never join pre-truncation flights, and the client-side clearUiEpoch
(prior commit) covers the converse (pre-rewind dispatches never CLEAR
a post-rewind latch).  Cold workstreams key at generation 0 and the
first post-load truncation bumps, so cold flights cannot straddle a
rewind either.

Unit test mirrors the #884 coalescing determinism scheme: the owner
parks in load_messages under generation 0, the mid-flight bump
simulates the truncation commit, and the post-bump request must MISS
the held flight (load_calls -> 2, no coalesced record).
Negative-controlled: reverting the key to (ws_id, limit) fails the
test.
2026-07-24 15:04:31 -07:00
Patrick Buckley 30b6ff7f7b fix(#894): rewind-freshness epoch closes the #884 joined-flight window; destroy aborts the bounded fetch
Review round 8 (2 major + 2 minor bug, 2 major + 3 small quality;
security/perf zero at five consecutive rounds).

- clearUiEpoch (r8 major): the #884 /history single-flight can hand a
  joiner a payload whose load_messages ran BEFORE the rewind committed
  (the flight key is (ws_id, limit); joining is invisible to the
  client, and the client seq stamp cannot see server-side staleness) —
  reachable single-user (rewind clicked during a truncated-resync
  fetch joins that flight) and multi-viewer (any concurrent pane's
  /history).  The joined payload rendered as 'success' and CLEARED the
  latch: the original over-rewind window, resurrected through the
  server seam.  Fix: the epoch bumps at clear_ui arrival, every
  dispatch captures it pre-await, and only a dispatch that post-dates
  the latest clear_ui may CLEAR the latch — a pre-rewind payload may
  still paint (stale-but-real posture, gate holds), the surviving
  latch arms the retry, and the retry's fresh dispatch starts a new
  flight with post-rewind truth.  Producer/consumer/placement pinned.
- destroy() aborts the in-flight bounded fetch (activeHistCtrl): the
  r7 15s bound alone pinned a destroyed pane's closure until it fired
  — the same dead-not-inert ruling destroy applies to staleRetryTimer.
  Pinned.
- stop(hard=True) no longer sets force_exit: it skipped the ASGI
  lifespan teardown and leaked the #885 daemon threads + sse_executor.
  The 2s graceful-shutdown timeout already force-closes open SSE, and
  the lifespan runs on both paths (docstring corrected; G6 re-verified
  — the orphan still manifests).
- G6 pacing sized above the scenario's worst-case deadline sum (~200s
  vs ~95s) so the in-process bash cannot resolve the orphan
  mid-scenario and degrade the detector to a false READY.
- Quality: the r6 reachability comments rewritten to the r7 truth
  (orphan REAL via hard crash; graceful-close-only synthesis); the
  bound's WIRING pinned (signal reaches getJSON; getJSON forwards
  init); _strip_comments deduped (4 inline copies); seq comment
  re-paired with its asserts; retry_fire window tail-anchored.

Full harness (16/16 scenarios) + full suite (9724) green on the prior
commit; 136 pins green here.
2026-07-24 15:04:31 -07:00
Patrick Buckley 412161aa4a fix(#894): live-set retirement policy — transport death is not retirement; bound the refetch await; G6 hard-kill detector
Review round 7 (2 major + 1 minor bug, 4 minor quality; security/perf
zero).  Both majors traced the r6 stratum:

- The closeStreamTransport drain of liveToolCalls rested on a false
  re-announcement premise (verified: replay_ok yields only events past
  the cursor; the coord fresh/truncated replay yields connected/status/
  pending-cards/verdicts, never tool_pending/tool_info).  An emptied
  set fails OPEN — a mid-batch redial plus a slow seedless refetch
  wiped the live batch.  Retirement policy re-derived at the decl: an
  id leaves on its RESULT, at the SETTLE edge, or with pane death;
  transport death is NOT a retirement event; a stale id fails CLOSED
  (skip, latch survives, settle heals).  Site-anchored pins: the one
  drain inside the idle/error block, the delete inside tool_result,
  the adds inside tool_pending/tool_info, and closeStreamTransport's
  comment-stripped code may not touch the set.
- G6's kill was not a kill: RecoveryServer.stop() gracefully closed
  workstreams, and session.cancel()'s bash path persisted 'Cancelled by
  user' BEFORE the reboot — the r6 'recovery synthesizes' ruling was
  observing the cancel path.  stop(hard=True) (skip the close sweep +
  uvicorn force_exit: a crash does not drain SSE) leaves the orphan
  genuinely unresulted — REACHABILITY FLIPS: the poisoned-pane state is
  real, the live-set hardening is reachably load-bearing, and G6 is now
  its behavioral detector: hard kill -> reload paints the orphan
  (asserted PRESENT) -> the seedless rewind renders THROUGH the residue
  (rewind-for-retry truth: the user message stays), negative-controlled
  against the DOM-probe encoding (stamps orphan1, hist2).  Discovered
  and tracked separately: a hard-crashed reborn node answers stale-high
  cursors with a silent fresh stream (no replay_truncated — the honest
  truncation signal rides gracefully-persisted state).
- refetchHistory's await is now bounded (AbortController + 15s, the
  coordSend shape): an accepted-never-answered /history pinned
  refetchesInFlight and permanently disabled both heals.  Pinned.

Quality: seq-producer position pinned earlier; the stale section-7
comment corrected; retry/backstop guard windows comment-stripped
(vacuous-by-comment-mention foreclosed); the shared G3/G4 double-fail
prologue extracted into _coord_stick_latch.  G3/G4/G6 re-run READY.
2026-07-24 15:04:31 -07:00
Patrick Buckley cc776bfb2d fix(#894): event-driven live-tool-call set replaces the DOM liveness probe; G6 synthesis tripwire
Review round 6 (1 bug find + 5 quality; security/perf zero).  The bug
finder out-traced r6-perf's dismissal: refetchHistory's own replay path
paints orphan batches (committed tool_calls, no persisted result) with
the same .conv-batch--running class the live path uses, and nothing
ever strips a dead orphan's class — so the r5 DOM-probed gate term
would let one orphan paint poison every seedless heal for the life of
the page (rewind/edit permanently dead; the seeded escape renders
through but REPAINTS the residue).

Reachability ruling (verified empirically): post-kill /history shows
the server synthesizes results for interrupted tool calls at recovery
('Cancelled by user. Outcome UNKNOWN'), so no persisted orphan exists
today and the poisoned state is unreachable — the client-side trace
was right, the server-side producer absent.  Hardened regardless:

- liveToolCalls: an event-driven Set — fed ONLY by live tool_pending/
  tool_info announces, retired by tool_result, drained at settle edges
  and closeStreamTransport, and NEVER touched by any render (pinned:
  refetchHistory's comment-stripped body may reference it exactly
  once — the gate read).  Liveness is read from the channel that
  creates the hazard, never from DOM a render can forge.
- G6 coord-orphan-rewind: pins the SERVER invariant the client's
  safety rests on — after a mid-bash node kill + reboot the batch must
  render RESULTED (no --running residue) and the seedless rewind flow
  must work end to end.  Honestly scoped in its docstring: with
  synthesis present a DOM-probe gate also passes, so the client
  discipline is carried by the static pin set.

Quality batch: the seq stamp's producer position pinned (captured
before the await — the twin of the counter-bracket pin); two stale
G5 synthetic-idle comments corrected to the replay_ok-precise shape;
contract-test docstring item 7 restated to the enforced
universal-vs-seedless split; char-count pin windows replaced with
function-boundary slices (both test files); section 6 reuses _fn_slice.

Full coord family C + G1-G6 READY; 136 pins green.
2026-07-24 15:04:31 -07:00
Patrick Buckley fac2393967 fix(#894): re-derive the render gate on DOM-live signals; drop the busy conflation
Review round 5 step-back (fix-era critical): the r4 gate's busy term
conflated 'a turn is executing' with 'this DOM holds live turn state'.
_editAndResend flips busy BEFORE its POST and /rewind emits only
clear_ui (no state_change), so the busy term skipped the truncation
render the rewind exists to produce and appended the resent bubble onto
the PRE-rewind transcript; /retry's regenerated turn likewise raced its
own clear_ui refetch.  The seam was re-derived once against the caller
x state matrix; the gate reads DOM-live signals only, split by scope:

- UNIVERSAL: dispatch seq (refetchSeq — overlapping fetches resolve
  last-DISPATCH-wins; an older snapshot landing late can neither
  double-render nor clear the latch over newer truth) and the content
  refs (skipping always beats stranding a ref; seeded callers null
  theirs before fetching, so it never blocks them).
- SEEDLESS-ONLY (keyed on the seedCursor arg): the
  .conv-batch--running DOM marker for the tool phase (NOT activeBatch —
  that is the pending-APPROVAL tracker, set only for opts.pending
  batches; ruled at-site), coordSend's busySource === 'optimistic'
  flavor (the one busy that marks un-committed DOM), and
  stream-OPENness (CONNECTING keeps the handle with a frozen cursor
  and a pending replay; handle-existence was not liveness — also
  applied to the retry's fire guard).  Seedless-only because the
  SEEDED resync renders over these deliberately: after a node dies
  mid-batch the --running class is dead residue no result will ever
  strip, and the resync's render IS the recovery — a universal term
  wedged the coord-restart scenario outright (family-run find; the
  r5 finders missed the seeded-path interaction).

Backstop comment corrected (r5): fresh/truncated SSE replays DO carry
a synthetic state_change (replay_ok does not) — a latched pane pays one
refetch per reconnect, bounded by reconnect jitter/backoff and #884's
server single-flight; heal-caused triggers remain structurally
impossible.  Caller fire-time ref guards demoted to the efficiency
layer at-site.  Contract test re-pins the gate: term presence in
comment-stripped CODE, universal-vs-seedless placement, wipe between
failure guard and latch-clear, no plain busy.  The edit-resend commit
gap under a second actor's clear_ui is accepted at-site (re-appears at
settle heal).  G4's honesty note names the tool-phase branch it
behaviorally detects; G5 wording replay_ok-precise.  Full coord family
(C + G1-G5) green; 136 pins green; 5 static mutants + the G4
behavioral control caught.
2026-07-24 15:04:31 -07:00
Patrick Buckley 54631d3111 fix(#894): render-time gate at the refetch chokepoint; liveness-gate the retry; G4/G5 scenarios
Review round 4 (1 major + 3 minor bug, 1 major + 3 minor quality;
bug-4≡q-2).  Two correctness findings landed in one seam — the
refetch-vs-live-state chokepoint — so the seam was redesigned once
against its matrix (caller x stream-state-at-render x refs-at-render)
instead of patched per-finding:

- RENDER-TIME gate inside refetchHistory, post-await, pre-wipe: the
  await is a real window (queued sends drain at exactly the idle edges
  the backstop rides; another operator on a shared coordinator can send
  any time; hide/suspend can land mid-fetch), and only the chokepoint
  can see across it.  Skip the wipe when a live turn exists (content
  refs — a wipe strands the bubble and loses the rest of the turn
  invisibly) or when a seedless render lost its idle/live-stream
  precondition (busy covers the tool phase the ref check can't see;
  a dead stream means rendering past the frozen cursor and
  double-rendering on the show-edge replay).  Both requirements key on
  the seedCursor ARG — seeded callers own their reconnect flows and
  legitimately rebuild mid-turn.  Skips leave the latch set; heals
  converge at the next organic settle.
- The retry's fire guard gains evtSource (close-on-hide keeps the timer
  armed by design; a hidden firing must not fetch).  The backstop needs
  no term — it runs inside SSE dispatch.
- Pins: producer ORDER (inc < await < finally < dec), ref-guard pairs
  on both heal arms, the else-if exclusivity structure, the render-gate
  order and terms, the evtSource guard tail.  Seven mutants, all caught.
- Harness: __esOpens gate in G1-G3 (a pre-connect rewind drops its
  clear_ui into a channel nobody joined and false-fails the scenario);
  G4 coord-heal-midturn (a turn started under a held backstop fetch
  survives its resolution; hist==2 is the discriminating bit — noted
  honestly in the docstring); G5 coord-hidden-retry (hidden0
  non-occurrence + organic-settle heal after show, per the accepted
  liveness-lag ruling — a quiet reconnect delivers no state_change
  edge).  Negative controls: gate-stripped stamps hist1; guard-less
  stamps hidden1.  Docstring gains the G-family catalog.
2026-07-24 15:04:31 -07:00
Patrick Buckley dd1db5c67e test(#894): pin the in-flight counter's producer bracketing
Review round 3 (bug/security/perf zero; 1 quality minor): the contract
test pinned both CONSUMERS of refetchesInFlight (the backstop and
retry-fire yield guards) but not the PRODUCER ++/-- pair — dropping the
bracketing would leave the counter at 0 and both consumer pins
vacuously green.  Count-pinned both sites; mutation-verified (the test
fails with the increment stripped).
2026-07-24 15:04:31 -07:00
Patrick Buckley ac441471f9 fix(#894): teardown-gate the retry ARM; pin both teardown sentinels
Review round 2 (1 minor bug + 1 minor quality, security/perf zero):

- The retry's arm site was gated on historyStale alone, so a clear_ui
  refetch in flight at destroy() that then FAILS re-arms the timer
  AFTER destroy's clearTimeout — a no-op fire (the visHandler fire
  guard holds) but the orphan pins the dead closure for its 2s delay,
  contradicting destroy's dead-not-inert invariant.  The arm gate is
  now historyStale && visHandler, matching the fire guard; the seam
  matrix (destroy/closeSession/live x arm-and-fire windows) closes
  with that one term.  The edit-resend in the same .then stays
  deliberately ungated on teardown: the rewind committed server-side
  and the workstream outlives the pane UI, so the committed edit
  still delivers (comment at site).
- Pins: the arm gate (mutation-verified — the contract test fails
  against a gate-stripped mutant), the fire guard's visHandler term
  (sole coordCloseSession protection), and the re-arm clearTimeout.

G1/G2/G3 re-run READY; 136 static-pin tests green.
2026-07-24 15:04:31 -07:00
Patrick Buckley 85214f433f test(#894): pin the yield guards; narrow the clear_ui clearTimeout pin
Review round 1 (0 correctness/security/perf; 1 minor + 1 nit) + the
suite's collateral:

- The latch-contract test now pins !refetchesInFlight on BOTH heal
  paths (backstop arm + retry fire guard) — the yield guard is
  load-bearing (same-snapshot double-render stomp without it) and was
  previously deletable with every test green.  Mutation-verified: the
  backstop pin fails against a guard-stripped coordinator.js.
- test_app_js.py's clear_ui pin narrowed from all-clearTimeout to
  clearTimeout(truncatedResyncTimer): the invariant it protects is that
  clear_ui carries no path-local cancel of the TRUNCATED repair intent;
  #894's staleRetryTimer re-arm cancel is the staleness latch's own
  machinery, deliberately armed there.
- _send_in_page's caller enumeration gains G3.
2026-07-24 15:04:31 -07:00
Patrick Buckley f58fcd1b0a test(e2e): coordinator rewind-window scenario trio with storm assertion (#894)
G1/G2/G3 mirror interactive's E2/E3/E4 for the coordinator pane, adapted
to its structure: the pane object exposes no messagesEl and no
latch/quiesce fields (closure-private state), so every probe reads the
public #coord-messages container and the runners drive the verdicts off
the fault layer's authoritative counters — the in-flight edge is the
history_requests bump (counted on arrival, before the delay hold), the
closed phase is proven by the gated click's POST non-occurrence, and the
latch-cleared proof is the reopen POST rather than a field read.

- G1 coord-rewind-window: the busy||historyStale gate under a held-open
  clear_ui refetch (delay_history); posts stays 1.
- G2 coord-rewind-failed-window: the failed-refetch aftermath — the
  latch survives the failed exit, the bounded 2s retry heals (its fetch
  held to defer the clear site), the healed render reopens the gate.
- G3 coord-stale-backstop: double failure (fail_history(2)) exhausts
  clear_ui refetch + retry; a plain send's organic idle edge fires the
  TRANSPORT-FREE backstop.  Storm assertion: events_requests delta is 0
  across the whole heal; history delta exactly 1.

Negative-control validated: pre-latch coordinator.js stamps
COORDREWINDWIN-posts2-rows0 and COORDREWINDFAIL-closed2-heal0; a
transport-touching backstop variant (loadHistoryThenReconnect) stamps
COORDSTALEBACKSTOP-...-sse1 — each detector has observed its bug.

The coord recovery page gains a scenario dispatch; the auto-send now
runs only for coord-restart (the rewind scenarios seed server-side),
verified against the existing coord-restart scenario.
2026-07-24 15:04:31 -07:00
Patrick Buckley f7ca4d295d fix(coordinator): historyStale latch closes the clear_ui over-rewind window (#894)
From clear_ui arrival until the next SUCCESSFUL refetchHistory render the
visible transcript is the stale pre-rewind DOM with busy false, so a
second rewind/edit click counted it and POSTed an over-large turn count
against the already-restructured server conversation (the #890 sibling,
pre-existing since #888 accepted the stale-interactive window).

Port of interactive.js's converged #890 latch design, adapted to coord's
structure (no load token, no replay quiesce, no ref-resetting render):

- historyStale latch: set at clear_ui arrival, cleared ONLY by the
  success-path render below the if-(!hist) failure guard — a flag would
  reopen on the failed exit, which is exactly the over-rewind window.
- Gates: _rewindToMessage / _editAndResend / _startEdit now require
  busy || historyStale; _rewindToTurns and _retryLast stay busy-only
  (explicit-arg / no-DOM-count — rulings at-site).
- Heal A: one bounded turn-free retry armed in clear_ui's .then; fire
  guards read the latch, refetchesInFlight (net-new await-window counter,
  coord's quiesce-free yield discriminator — a COUNT because overlapping
  fetches are reachable), busy, the streaming refs (load-bearing: coord's
  refetch does not reset refs), and visHandler (teardown sentinel).
- Heal B: idle-edge backstop as the else-if behind the truncated-resync
  consumer — TRANSPORT-FREE by ruling (plain seedless refetchHistory;
  a reconnecting heal draws the synthetic state_change:idle back into
  its own trigger = zero-backoff storm against a recovering node).
  Carries ref guards the interactive template omits: this arm also
  serves error edges where no stream_end nulled the refs.
- Teardown: destroy() cancels the retry timer (terminal-only);
  closeStreamTransport deliberately does not (redials keep heal intent).

Static pins: the latch contract (set/clear/gate sites, transport-free
backstop, bounded arm, teardown split) + the widened guard-before-wipe
window; the contract pin fails against the pre-latch code.
2026-07-24 15:04:31 -07:00
Patrick Buckley 09a27cfce9 docs(#881): faithful token_hex(8) test epoch; document inline shutdown put
PR #896 review follow-up, no behavior change:
- Pinned test EPOCH was 32-bit (token_hex(4)) with a matching comment, but
  production widened to token_hex(8) in 2b3d0687 and the same file already
  pins token_hex(8) at line 401. Widen EPOCH to 16 hex chars + fix the comment.
- Document why the fanout shutdown sentinel put stays inline on the loop: the
  consumer is still alive and drains via non-blocking fan-out, so it returns
  at once; the 1s timeout is a ceiling that never binds (off-loop is reserved
  for the multi-second joins).
2026-07-22 23:44:04 -07:00
Patrick Buckley 52d38f91b1 ci: raise the test job timeout to 30 minutes
The suite's growth (~9.7k tests, coverage-instrumented, 3-version
matrix) started brushing the 20-minute hang cap on healthy runs; 30
keeps the hang-catching semantics with headroom.
2026-07-22 23:44:04 -07:00
Patrick Buckley 5386d598ef test(e2e): native-transport roster scenario (F2) + strict absence assertions (#881)
Scenario F splits into F1 (manual ?last_event_id= transport) and F2, the
native-header sibling — the only behavioral coverage of two pure-browser
semantics no Tier-1 harness can express: the auto-reconnect header echo,
and id-less frames inheriting the connection's persisted lastEventId
(the mechanism behind app.js's node_snapshot-branch clear).  F2's phase
C is the round-3 fix's discriminator: after the native heal, a forced
manual reconnect must go CURSORLESS with no second truncated round
(pre-fix: cursor1-trunc2), guarded by an idFrames precondition against
the aggregate tick.

Two harness seams earned by F2's first failures, both documented at
site: a failed EventSource reconnect attempt is TERMINAL per WHATWG, so
the restart must never expose a refused window — a SO_REUSEPORT
placeholder binds before the old node stops and hands its backlog to the
successor's uvicorn (make_listen_socket + RecoveryServer sock
injection); and an SSE stream still open at stop() parked uvicorn's
graceful drain indefinitely — timeout_graceful_shutdown=2 bounds it with
the #885 lifespan teardown intact.

Absence assertions tightened (round-4 review): ghost-gone now requires
absence from BOTH the model and the rail via _roster_absent_ws — the
negated AND-membership helper De Morganed into either-surface and could
false-pass a rail-render regression.
2026-07-22 23:44:04 -07:00
Patrick Buckley 8a67f91d8b fix(server): widen the boot epoch to 64 bits; docstring precision (#881)
token_hex(4) left the epoch equality check — the only thing between a
prior-boot cursor and a silent replay_ok-empty alias — at 2^-32 per
same-node restart-pair; 64 bits puts a fleet-lifetime of restarts
engineered far below threshold (review round 4, classified
design-margin).  Docstring rounds from the same pass: the resume
contract now notes reason=boot_epoch also covers the same-epoch
empty-ring fail-safe (not exclusively foreign epochs), and the collector
ruling says precisely that the staleness CHECK and envelope can never
fire there — the epoch-tagged ids are on the wire, just never read.
2026-07-22 23:44:04 -07:00
Patrick Buckley 58bd607f49 fix(ui): the snapshot recovery floor clears the global resume cursor (#881)
On a NATIVE reconnect into a boot_epoch truncation, the envelope and
node_snapshot frames are id-less, and an id-less frame's MessageEvent
inherits the connection's persisted pre-restart lastEventId — so the
pre-dispatch capture re-stored the dead cursor on the snapshot frame,
undoing the truncated branch's clear (a manual reconnect's fresh
EventSource starts with an empty string, which the guard blocks).  A
manual reconnect racing in before the next id-bearing frame then
re-presented the dead cursor for a redundant, self-healing truncated
round.  The snapshot branch now clears the cursor before the roster
rebuild — dead in every case that draws a snapshot (fresh has none,
truncated's is spent) — and the tripwire pins all three clear sites so a
simplify pass cannot drop one (round-3 review; verify classified the
mechanism redundancy-not-correctness: a cross-epoch cursor can only ever
redraw truncated+snapshot, never the silent ghost shape).
2026-07-22 23:44:04 -07:00
Patrick Buckley 4232136d26 fix(server): de-register the global listener when the reconnect window exits early (#881)
Round-1's lock-scope fix moved the snapshot build after listener
registration but left it unguarded: a raising _build_node_snapshot
(storage reads, per-ws locks) propagated before the generator — whose
finally owns de-registration — ever existed, stranding a dead 1000-slot
queue in the fan-out list forever (the fan-out thread never removes
listeners; pre-branch the append was the LAST locked statement precisely
so a raising build could not strand it).  The whole post-registration
window (build, log, response construction) now runs under a guard that
de-registers on ANY exit and re-raises; _deregister is shared with the
generator's finally so the discipline has one owner.  BaseException
because the window must stay guarded even if a future edit introduces an
await (today it is await-free, so a cancel cannot land inside it).

Tests (round-2 review): the leak path is pinned (raising build →
exception propagates AND the listener list is empty); the
registration-before-build + lock-released ordering is pinned by a probe
builder asserting both at build time; the caught-up-cursor test is
rebuilt around a sentinel live event so it asserts the no-envelope shape
positively instead of truncating the drain at the retry frame.
2026-07-22 23:44:04 -07:00
Patrick Buckley d84a3c6eb3 docs(tests): honest coverage pointer for the stubbed snapshot builder (#881)
test_console.py covers the CONSUMER side of node_snapshot (hand-built
dicts fed to the collector), not _build_node_snapshot's production —
the helper docstring claimed otherwise.  Point at the real end-to-end
coverage (the roster-restart scenario: membership + evict) and state
plainly that the producer's field projection has no direct unit test
(review round 1, quality finding).
2026-07-22 23:44:04 -07:00
Patrick Buckley a4a7c960db fix(server): build reconnect snapshots outside the fan-out lock (#881)
_build_node_snapshot is an O(workstreams) walk taking each ws's _ws_lock;
under global_listeners_lock it serialized a restart herd's stale-cursor
reconnects against each other and against the fanout thread's per-event
stamping — stalling roster delivery to every listener exactly while the
reborn node emits its re-open events.  Listener registration stays under
the lock (the ordering that guarantees no loss); the snapshot now builds
after release, keyed off replay_status so the build predicate and the
generator's emission branch stay one rule.  A delta stamped during the
build is both reflected in the newer snapshot and queued behind it —
absorbed idempotently by the state-of-world consumers; the endpoint
docstring's atomicity claim is rewritten to this contract (review round
1, perf finding).
2026-07-22 23:44:04 -07:00
Patrick Buckley ea706bf4c0 test(e2e): roster-restart scenario proves the global boot-epoch heal (#881)
Scenario F drives the REAL node dashboard (/ + app.js) through a node
restart on the global stream — no custom page; transport instrumentation
is injected via CDP addScriptToEvaluateOnNewDocument, scoped to
/events/global URLs so per-ws streams can't pollute the counters.
Phase A is the negative control: live roster, live cursor, zero
replay_truncated.  Phase B: hide, force the CLOSED state (a closed
EventSource never auto-retries, making the show edge's manual reconnect
the only reconnect), restart the node re-opening only one of two
workstreams, show.  Asserted: cursor presented via ?last_event_id= and
replay_truncated observed at the transport, the not-reopened
workstream's ghost evicted from the roster model and rail (the dashboard
table's membership refreshes on interaction by design — documented at
_roster_has_ws), and the reborn node's global_events_requests counter
proves the reconnect hit the real endpoint.  The native header
transport differs only in carriage and is pinned by the Tier-1
boot-epoch tests.
2026-07-22 23:44:04 -07:00
Patrick Buckley a935ae3106 fix(server): give the lifespan daemon threads a real shutdown (#885)
_global_fanout_thread, _aggregate_emitter_thread, and
_idle_cleanup_thread were daemon threads with no stop signal — shutdown
abandoned them mid-loop.  The sleep-loop pair now waits on a shared
Event (wait doubles as the tick sleep, so a set wakes them immediately);
the fanout exits on an identity-checked queue sentinel, FIFO-draining
everything enqueued before it (sessions close earlier in the shutdown
tail, so their final events still fan out).  Joins are bounded and
off-loop; daemon=True stays as the backstop for a join timeout, not the
mechanism.  The recovery harness drops its thread-neutering workaround
(module docstring piece 4) — the global lane now runs REAL in harness
boots, which the #881 roster-restart scenario requires.
2026-07-22 23:44:04 -07:00
Patrick Buckley 22c905b2ec feat(ui): present the global resume cursor on manual reconnects (#881)
The global stream's manual reconnects were pinned cursorless because a
stale cursor on the reborn ring drew replay_ok-empty with no snapshot
(the ghost-roster shape).  With epoch-tagged ids that shape is
unreachable — a stale cursor now draws replay_truncated + a fresh
node_snapshot — so app.js captures e.lastEventId (MessageEvent, house
guard form), presents it via ?last_event_id= on manual reconnects, and
clears it where the record dies: the replay_truncated handler and
onLogout.  The cursor stays an opaque string end to end; the tripwire
that pinned cursorlessness now pins the capture, the guarded query-param
presentation, and the never-parse-numerically discipline instead.
2026-07-22 23:44:04 -07:00
Patrick Buckley e640aeda66 fix(server): boot-epoch staleness signal on the global SSE stream (#881)
The global ring's counter is process-local and reboots at 0, so after a
node restart a pre-restart cursor was first invisibly ahead of the reborn
ring (replay_ok with an empty slice) and then aliased into the new id
space as the counter re-grew — both silently skipping the restart
boundary (ghost rosters).  Every global SSE id is now
"{boot_epoch}-{counter}" (per-process nonce); the browser echoes it
verbatim on native reconnect, so provenance rides every path with zero
client cooperation.  A cursor from any other epoch — prior boot, another
node, a pre-epoch bare-int client, garbage — draws replay_truncated
(reason=boot_epoch, loss unknowable so the numeric fields are omitted)
plus the node_snapshot recovery floor; in-epoch ring misses keep honest
lost_count under reason=ring_evicted.  Same-epoch cursors run the ring
logic unchanged.  Chokepoint log line added; per-ws ids deliberately stay
bare ints (storage-seeded counter — asymmetry documented at both sites);
collector audit ruling recorded at its cursorless connect.
2026-07-22 23:44:04 -07:00
Patrick Buckley af918c321c docs(interactive): correct #890 gate refs + rule the heal's fire-and-forget
Addresses the Copilot review of #895 (docs/comments only, no behavior change):

- recovery_e2e.py / _sse_recovery_server.py: the mutating affordance gate
  is `busy || _historyStale`, not the superseded `busy || _replayQueue`
  quiesce gate the r3 latch replaced — corrected both docstrings (E2 now
  matches E3).
- interactive.js cross-ws supersession: the branch drops the pending edit
  and releases busy but does NOT clear `_historyStale` (its sole clear
  site is replayHistory) — reworded so it no longer implies the latch is
  released.
- interactive.js idle-edge backstop + bounded retry: documented that the
  fire-and-forget `_refetchHistory` (no `.catch`) is deliberate — no
  composer state to un-strand there, unlike the primary clear_ui caller,
  so a render throw stays loud (peer of the load path's `.finally`).
2026-07-22 17:32:25 -07:00
Patrick Buckley 185a73ce4d docs(coord): repoint the replayHistory parity citation to shared_static/interactive.js
Rider from the session queue: the comment cited ui/static/app.js
Pane.replayHistory, which moved to shared_static/interactive.js in the
L-shell step-5a lift — the old path no longer exists.
2026-07-22 17:32:25 -07:00
Patrick Buckley 74eedff1a8 test(e2e): fault-injection knobs + five recovery scenarios for the /history failure paths
RecoveryServer grows an in-process fault layer (pure-ASGI wrapper; the
production app is untouched): fail_history(count) serves minimal 500s
for the next N GET /history requests, delay_history(ms) holds responses
to widen or hold open a refetch window, and per-route request counters
(history_requests, rewind_requests) let scenarios assert backend state
rather than scripted absence.

Five scenarios on that layer, all stamping RECOVERY-READY/FAILED
titles like their siblings:

- fail-refetch: hide mid-turn -> restart -> failed first resync ->
  the stale transcript survives (no wipe, no empty-state) while the
  truncation record stays armed -> the connect-chokepoint retry heals
  (history_requests proves the re-fetch). The #890 acceptance
  contract, browser-observed end to end.
- stale-ref-reload: mid-segment transport death -> turn completes
  during the outage -> failed unarmed same-ws reload -> the next
  turn renders in a FRESH bubble and the stale bubble's text is
  unchanged (regression test for the resumability-gated ref reset).
- rewind-window: a second rewind clicked during a held clear_ui
  refetch window never reaches the server (rewind_requests == 1) and
  the transcript reflects one rewind (regression test for the
  busy-or-latch affordance gate, in-window arm).
- rewind-failed-window: the failed-fetch AFTERMATH sibling — the
  refetch 500s, the staleness latch keeps the gate closed over the
  stale rows (rewind_requests stuck at 1, proven latch-not-quiesce
  via a settle-poll), the bounded turn-free retry heals (3 -> 1 user
  rows), and only then does the gate reopen (rewind_requests == 2).

Negative-control validated: with the interactive.js fixes reverted,
stale-ref-reload stamps fresh0-unchanged0 (the concatenation bug),
rewind-window stamps posts2-rows0 (the in-window over-rewind), and
rewind-failed-window stamps closed2-rows0 (the failed-exit
over-rewind) — every detector observes its bug, then stamps READY
again with the fixes restored.
2026-07-22 17:32:25 -07:00
Patrick Buckley 99fa3def1b fix(interactive): preserve the pane on a failed /history refetch (#890)
Port the coordinator's #882 G3 guard-before-wipe: the wipe + streaming-
ref reset live in replayHistory, reached only on a successful fetch.

- clear_ui no longer pre-wipes the transcript; a failed refetch during
  a rewind/retry/resume replay keeps stale-but-real content instead of
  blanking the highest-traffic pane on a live stream (/history
  failures cluster in exactly the restart windows that emit clear_ui).
- _refetchHistory's failure branch is a DOM/ref/repair-intent no-op:
  no empty-state hint below stale content (the old resync-route wart),
  no streaming-ref reset (which orphaned a mid-jitter turn's bubble on
  the resync route); the truncation record stays armed for the
  connect-chokepoint retry; only the quiesce releases.
- _loadHistoryThenConnect resets streaming refs on a ws SWITCH only --
  the old ws's refs otherwise survive a failed fetch into the new ws's
  stream; a same-ws reload keeps them so the reconnect resumes the
  mid-jitter bubble instead of orphaning it.
- The factory connect() empty-state pre-seed is now the sole producer
  of the failed-first-paint placeholder -- documented load-bearing.

The edit-and-resend dispatch, cross-ws supersession, and repair-intent
lifecycle are unchanged; a failed fetch keeps the resend firing (the
rewind already committed server-side), mirroring coord.

Pinned by test_interactive_refetch_failure_preserves_the_pane (the
mirror of coord's test_coordinator_refetch_failure_preserves_the_pane)
plus the re-pointed quiesce/agent-tracking pin.
2026-07-22 17:32:25 -07:00
Patrick Buckley 7f74e9594e feat(session): coalesce concurrent /history reconstructions per workstream (#884)
After a node restart every open pane resyncs via REST /history inside
the same jitter window; client jitter spreads the peak but not the
total. Concurrent requests for the same (ws_id, limit) now share ONE
reconstruction (load_messages -> decoration -> projection) via a
single-flight task map in the handler closure.

Deliberately single-flight only, no TTL cache: the payload depends on
live-mutable inputs with no total cheap invalidation signal (the
surface_persisted_reasoning registry toggle emits no per-ws event;
cold workstreams have no event counter), so a cache could serve stale
reasoning/approval/cursor state for its whole TTL, while a joiner's
worst-case staleness equals the flight duration -- the window a lone
slow request already exposes.

All auth/tenant/kind/existence gates stay per-request ahead of the
join; only the caller-independent reconstruction is shared. A shared
draw that hit a transient load_messages failure is not fanned out:
joiners retry once, independently, so one storage blip cannot wipe
every coalesced pane (the 200-empty payload renders as an
authoritative empty pane in both clients, and the seedless clear_ui
path has no SSE redelivery to repair it). The flight is a detached
task (awaiters shield it) so an owner disconnect cannot strand
joiners, and each task pops its own key in a finally, so the map only
ever holds in-flight work. ws.history.load_failed rises to warning:
it now names the draw that triggers joiner retries and renders as a
pane wipe.
2026-07-22 15:18:50 -07:00
Patrick Buckley b38e9be17c docs(session): scope the zero-band geometry claims to the default compact threshold
The drain comment and the architecture docs stated the zero-budget band
relative to the auto-compact threshold as if 0.8 were universal
("well below the auto-compact threshold"); with an operator-set
auto_compact_pct under the ~70% zero point the claim reads inverted.
State the geometry against the DEFAULT threshold and make explicit what
was always true of the mechanism: the trigger's predicate is the
exhausted budget itself, never a threshold, so with low thresholds the
owed path compacts first and the trigger is its bail/insufficient
backstop.
2026-07-21 17:48:09 -07:00
Patrick Buckley 6c4c848a08 fix(session): survive tool-result truncation at zero context budget (#883)
At an exhausted context budget the drain loop replaced every tool result
with a placeholder that read as a successful-but-trimmed call. For
structural results — spawn_workstream's ws_id, the tasks scratchpad —
the model lost the handle orchestration depends on and silently
stalled, while the UI (told the real summary before the drain) kept
showing success. Worse, the budget zeroes near 70% fullness when
max_tokens ≥ context_window/4, well below the 80% auto-compact
threshold, so a stalled coordinator could sit in that band indefinitely
with no compaction ever firing.

Three guarantees at the truncation seam, one renewal trigger at the
drain:

- structural-tool and error results get a guaranteed 2048-char
  admission floor (head+tail beyond it) — never the zero-budget drop
- any result at or under the floor passes verbatim (denial notices,
  spawn acks: never destroy what is smaller than the guarantee)
- bulky non-structural results get an explicit drop notice stating the
  call RAN but its output could not be admitted — never a trim
  impersonation the model cannot distinguish from success
- a zero truncation budget triggers one mid-turn compaction (no
  threshold_pct — none was evaluated, same rule as the ctx-overflow
  retry), closing the 70-80% band where the budget zeroed but
  compaction was never owed

Background-bash spawn acks ride the small-result pass; a name-keyed
floor cannot distinguish them from foreground bash — see #891.
2026-07-21 17:48:09 -07:00
Patrick Buckley 51bb525b27 chore: bump version to 1.8.0a4 2026-07-21 15:30:35 -07:00
Patrick Buckley 51ad8366d9 fix(interactive): supersede all repair intent on a full history render
A mid-stream replay_truncated latches _pendingTruncatedResync; a
clear_ui rebuild (rewind / edit-and-resend) heals the gap but left the
latch — and any pending jittered _resyncTimer — armed, because clear_ui
keeps the stream live and only disconnectSSE cancelled the timer.  The
next idle edge then fired a phantom _loadHistoryThenConnect against the
already-repaired gap: a false truncatedGaps bump and a needless
teardown, and on the phantom's failed-fetch leg the reconnect went
cursorless (_lastEventId nulled with no record armed) with nothing
left to re-cover the suspend window.

replayHistory now clears the gap record, the deferred latch, and the
pending timer together — the same one-site supersession the coordinator
port established in refetchHistory.  The latch/timer clears are no-ops
on every _loadHistoryThenConnect flavor (each clears both before its
fetch); the clear_ui heal is the path they exist for.  A failed fetch
still clears none (it never reaches replayHistory), keeping the connect
chokepoint's retry armed.

Found as a latent shared shape by the #882 review's round-4 pass and
confirmed against this file; pinned in the fresh-connect/churn-limit
test alongside a guard that clear_ui never grows a path-local cancel.
2026-07-21 15:25:51 -07:00
Patrick Buckley 14c246a569 fix(coord): port the truncated-recovery design from interactive (#882)
replay_truncated is now a dead-stream signal, mirroring the converged
interactive.js machinery:

- loadHistoryThenReconnect: tear the transport down first, drop the live
  cursor, refetch /history with cursor adoption, reconnect in .finally.
  The old in-place refetch discarded the /history cursor while /history
  trims the trailing in-flight turn whenever it returns one — a mid-run
  truncation wiped the executing turn with no redelivery and later tool
  results orphaned into top-level bubbles.  Both consumption sites
  (immediate branch and idle-edge deferred consumer) route through it.
  Dropping the cursor before the fetch is load-bearing, not just parity:
  a post-restart heal on an idle ws gets no /history cursor, and
  re-presenting the frozen pre-restart cursor against the reseeded empty
  ring draws replay_truncated forever — an envelope→resync loop that
  parks the pane in degraded cooldown cycles (caught by the new
  browser-level scenario, invisible to source-pattern tests).
- truncatedFromCursor: the truncation-time cursor, recorded keep-oldest
  at the envelope and cleared only by a successful full render; the
  connect chokepoint presents it over the live cursor so every manual
  reconnect re-draws the envelope and the repair survives any teardown
  interleaving (hide/show, degraded cooldown, CLOSED retry, failed
  fetch).
- churn ladder: truncated resyncs feed the same rolling window as
  overflow closes via the extracted recordChurnAndMaybeTrip(); a trip
  skips the resync (the degraded wake re-arms via the chokepoint).
- herd jitter: resyncs start behind a 0..TRUNCATED_RESYNC_JITTER_MS
  spread; one pending resync at a time; the fire path nulls its handle
  before loading; closeStreamTransport owns cancellation.
- sidebar refresh: while a truncation gap is on record the gap machinery
  owns recovery outright — the envelope refreshes once per NEW gap, one
  heal-time refresh covers the retry window, and onopen's no-cursor /
  long-gap arm stands down — so a failed-resync retry loop cannot
  stampede /children + /tasks un-jittered once per reconnect through
  either path.
- a failed /history refetch no longer blanks the pane (wipe + tracking
  resets sit below the !hist guard); a successful full render supersedes
  ALL pending repair intent in one place (gap record, deferred latch,
  pending resync timer) so a heal can never strand a phantom resync.

Behavioral coverage: scripts/recovery_e2e.py gains --scenario
coord-restart — the REAL coordinator pane (chrome, cookie auth,
EventSource, connect chokepoint, resync, churn limiter) mounted against
the interactive recovery node (/coord-static + /coord-recovery), driven
through hide → node restart → show over CDP, asserting the envelope is
drawn, the hidden-window turns heal, the stream re-opens, and the pane
converges.  Revised the two tests that pinned the in-place shape, added
the coordinator mirror of interactive's fresh-connect/churn-limit pins
(keep-oldest record, chokepoint consult, clear-on-render, shared churn
step, trip-skip, jitter scheduler, cancellation site, cursor drop,
per-gap sidebar dedup).
2026-07-21 15:05:23 -07:00
Patrick Buckley 431ef7c2fe ci: give the e2e_recovery suite its own lane exclusion, drop the live co-mark
The recovery e2e tests run a scripted provider — no LLM backend — so the
live co-mark was a lie told to keep the existing CI expression skipping
them. Both CI lanes now deselect explicitly via
-m "not live and not e2e_recovery", and the tests carry only their
honest marker. Select with -m e2e_recovery.
2026-07-20 22:38:32 -07:00
Patrick Buckley 7a43d37f8b fix(sse): capture the reconnect cursor from the MessageEvent, not the EventSource
All three clients read lastEventId off the EventSource object, but per
WHATWG the property lives on the MessageEvent — EventSource exposes only
url/withCredentials/readyState. The object-form reads were dead
conditionals in every real browser: the cursor never tracked live
traffic, every MANUAL reconnect (close-on-hide show edge, degraded-
ladder retry, recover beat) opened cursorless as a fresh connect, and a
fresh connect does not refetch history — so turns committed while a tab
was hidden silently never painted. This is the cleanest mechanism behind
the 'turn disappeared, never healed' field reports, and it gated the
branch's recovery fixes: without a presented cursor, the empty-ring
truncated honesty could never fire for hidden-tab restarts and the
truncation record captured null. Native auto-reconnects were unaffected
(the browser sends its internal Last-Event-ID header), which is why the
bug stayed invisible: transient blips healed, deliberate closes lost.

Capture e.lastEventId in each onmessage instead, guarded != null and
!== "" — no-id frames carry the empty string and "0" is a valid id (the
error-surface snap_seq can be 0 on a brand-new workstream). The
coordinator's counter-reset detector, which compared against the same
dead property and so never fired, now works as documented.

Found by the recovery harness's first real-browser run: source-pattern
tests pin a wrong-object property read as happily as a right one, so a
tripwire test now forbids the object form by name across all three
clients, and Tier-2 scenario B is upgraded to hide MID-turn and require
the browser-observed replay_truncated envelope plus the healed gap
(RECOVERY-READY-RESTART-rows1-trunc1 demonstrated; was trunc0).
2026-07-20 22:38:32 -07:00
Patrick Buckley 43561c9b08 test(sse): end-to-end recovery harness (server-contract + browser livepass)
Tier 1 (tests/test_sse_recovery_e2e.py, opt-in e2e_recovery marker): six
scenarios against a real interactive server with a scripted provider and
ephemeral DBs — storm batching without loss, slow-consumer overflow with
lossless ring replay, mid-run truncation with cursor-adoption rebuild,
restart truncated-honesty (exact lost_count; no-loss variant replay_ok),
failed-resync retry via the truncation record, and sub-agent storm
attribution. BrowserlikeSSEClient (tests/_sse_recovery_helpers.py)
implements the browser cursor contract; RecoveryServer
(tests/_sse_recovery_server.py) boots the real app per test.

Tier 2 (scripts/recovery_e2e.py): the livepass idiom against a REAL node
— boots the real InteractivePane over real EventSource/authFetch, with a
dependency-free CDP runner driving the storm and hide-restart-show
scenarios; document.title stamps verdicts so a broken state cannot pass
silently.

Events are produced by the real session engine through the provider
boundary — no synthetic frames; teardown leaves no leaked threads; the
default suite keeps these deselected.
2026-07-20 22:38:32 -07:00
Patrick Buckley 9733490aac feat(sse): coalesce tool_output_chunk emission per call_id
Line-chatty tools under the 4-wide pool emitted one SSE event per
stdout line — the event-storm source that overflowed listener queues
under parallel task agents — and each line's _enqueue force-flushed
the pending token batch, defeating token batching too.

Chunks now buffer per call_id in SessionUIBase and flush as one
concatenated event on the shared window/size cadence, bypassing
_enqueue entirely. Ordering rulings from the dataflow pass:

- The load-bearing ordering is chunk-vs-its-own tool_result (the
  client removes the streaming pre at the result render), enforced by
  a terminal flush+close in on_tool_result before the result enqueues.
- Chunk-vs-content interleaving is cosmetic (independent DOM
  subtrees), so chunk traffic no longer touches the token batch.
- A chunk arriving after its call closed is a leaked drain thread
  past the join timeout: discarded (the rendered result carries the
  complete output), never mispainted or flushed unstamped.
- Teardown backstops (stream_end, the idle/error snapshot chokepoint,
  turn commit, on_error) flush all pending batches; on_turn_start
  discards stale-crash residue and resets the closed-call ledger.

The CLI is untouched by construction (TerminalUI implements the
SessionUI Protocol directly; its chunk hook is a no-op) and the
single-producer-per-call_id topology the batcher's ordering assumes
is pinned by a producer-surface test.
2026-07-20 22:38:32 -07:00
Patrick Buckley c17c53c088 fix(sse): make truncated replay recovery lossless and honest
Two fixes for the field reports of permanently missing turns,
stuck-busy panes, and sub-agent tool calls escaping to the top level:

- Client: a replay_truncated envelope now runs the full fresh-connect
  flow (_loadHistoryThenConnect — disconnect first, /history, adopt
  the resume cursor, reconnect) on both the immediate and idle-edge
  branches. The old in-place refetch discarded the cursor while
  /history trims the trailing in-flight turn whenever it returns one,
  so a mid-run truncation wiped the executing turn (task cards
  included) with no redelivery; the orphan grace then escaped the
  still-streaming children to top-level rows.

- Server: register_listener_with_replay reports truncated (not a
  silent replay_ok) on an empty ring when the storage-seeded event
  counter proves the client lost events — the rehydrate/node-restart
  case that previously skipped the gap unsignalled. can_replay_from
  deliberately stays False on an empty ring (docstrings record the
  asymmetry ruling).

Truncated resyncs count into the same degraded catch-up window as
overflow closes, bounding the re-truncation loop under sustained
eviction; the limiter check runs before the resync starts so its
.finally reconnect cannot defeat a cooldown it just triggered.

Observability: _streamHealth.truncatedResyncs client-side and a
ws.events.replay_truncated log line at the envelope chokepoint.
Known-gap breadcrumbs: #881 (node-global stream), #882 (coordinator
pane parity).
2026-07-20 22:38:32 -07:00
Patrick Buckley 482957ce2f docs(auth): correct require_project predicate docstring
The docstring claimed the non-string project_id coercion matched both
_coord_create_build_kwargs and the interactive create path, but
_interactive_create_build_kwargs passes body.get("project_id") through
rather than coercing. Restate it as the gate's own rule — only a
non-empty stripped string counts as an attached project — and reference
only the coordinator persistence that actually matches. Behavior
unchanged.
2026-07-20 11:16:08 -07:00
Patrick Buckley d7331ae18b feat(coordinator): extend server.require_project to coordinator creates
Wire create_gate_require_project=True on coord_endpoint_config: a
projectless coordinator create on the console is refused with the same
coded 400 as interactive creates. Operator tokens get no exemption; the
sessions a coordinator spawns remain exempt via the token_source branch
in require_project_denies_create (child spawns, a different seam).

The gate predicate now reads "no project" the way the create path
actually persists it — a non-string body value (int/bool/list/dict) is
coerced to absent, matching _coord_create_build_kwargs and the
interactive create — so a truthy non-string like project_id:123 cannot
stringify past the gate and mint a projectless session. Without this the
three sites disagreed: the old str(project_id or "") stringified a
number to a truthy value and waved it through while build_kwargs stored
None. Interactive was unaffected (its validator stringifies and 400s
first); the fix is at the shared predicate as defense-in-depth for both.

The console launcher's project picker mirrors the interactive strict
treatment when the flag is on — the seeded placeholder retitles to
"Select a project…" (or "No projects available") via
setOptionPlaceholder, computed before the + New project… sentinel is
appended; the server's coded 400 stays the enforcement. Settings label
and help text updated to say coordinators are covered and only
coordinator-SPAWNED sessions are exempt.

Real-mount wiring tests drive the mounted console endpoint end to end
(the synthetic-cfg tests can't catch a mis-wire on the actual mount),
including a non-string-project_id bypass regression, with an operator
token that carries admin.coordinator without the service scope.
2026-07-20 11:16:08 -07:00
Patrick Buckley a16e6d66d8 chore: bump version to 1.8.0a3 2026-07-20 07:38:27 -07:00
Patrick Buckley 984a10307e feat(coordinator): MCP tool surface for coordinator sessions (#725)
Coordinator-kind workstreams get the same MCP surface as interactive
sessions — tools, resources, and prompts (read_resource/use_prompt go
dual-kind) — gated per-persona exactly like interactive, with no
separate feature flag.

The console hosts its manager with node parity end to end: boot calls
create_mcp_client inline (same catalog resolution: DB rows, then
mcp.config_path, then this host's config.toml), the admin reload
fan-out lazily constructs and reconciles it under a lock (the node's
unlocked equivalent is #873), per-server refresh/reconnect and the
admin MCP status view cover it under the collector's console
pseudo-node id, and shutdown follows LIFO teardown. Sessions read the
live manager through a per-construction getter — the console
counterpart of the node factory's mcp_ref[0] read; client presence is
the session-level contract, and the kind-aware tool assembly runs the
same listener/prime/rebind skeleton as interactive. bind_acting_user
re-scopes listeners and per-user pools, which is security-critical for
multi-sender coordinators.

The wire-safety status projections move verbatim to core/mcp_utils so
both hosts present one schema (node endpoint bodies byte-identical);
the console's per-server action classification is a pinned COPY of the
node endpoints', with a parity test driving both sides across the
outcome matrix that fails if either drifts.

The shared MCP error card (consent / re-consent / forbidden / operator)
moves to mcp_error.js + mcp_error.css, linked by all three card hosts
and pinned by className→rule and host→link parity tests; the module
joins the whole-file sink-scan and var-ratchet lists. Reload reporting
is honest about the console entry: excluded from the unreached-node
warning's list and denominator, and the toast claims "+ console" only
for a real reconcile, with an explicit note on failure.

The pending-consent badge (#874's console half) ships too: the console
defines the same onConsentDetected seam the node dashboard exposes —
lighting up the shared pane host's existing bridge for hosted
interactive panes — and the coordinator pane threads its card's
detections through the single MCP-error helper. The badge rides the
Admin > MCP Servers rail row, hydrates at boot from the Phase 9
pending-consent endpoint the console already serves, re-syncs to DB
truth when the operator views the MCP panel, and the rail-less
standalone page carries a status-bar chip instead. A coordinator that
hits a consent wall unattended now has a persistent, glanceable signal.

Pre-existing bugs fixed along the way: create_mcp_client returned None
on pool-only installs, leaving any host managerless after restart until
the next admin MCP write; admin_import_mcp_config never scheduled the
reload fan-out (stale catalogs after import); the admin settings UI
rendered the coordinator settings section unordered and unlabeled.
Follow-ups: #873 (node reload double-construct race); #874 narrows to
the admin-MCP-view per-server indicator.
2026-07-20 07:25:56 -07:00
renovate[bot] 5ae963cb49 chore(deps): lock file maintenance 2026-07-20 07:21:40 -07:00
github-actions[bot] e4604c278e chore: download vendored JS files 2026-07-20 07:21:23 -07:00
renovate[bot] c3306be442 chore(deps): update dependency katex to v0.18.1 2026-07-20 07:21:23 -07:00
renovate[bot] 686ddf2414 chore(deps): update actions/setup-python action to v7 2026-07-20 02:39:57 -07:00
renovate[bot] b89fe0fba2 chore(deps): update pypa/gh-action-pypi-publish digest to ba38be9 2026-07-20 02:39:40 -07:00
Patrick Buckley c4d180aa04 refactor(session): single assignment path for interactive tool lanes
Apply review round-2 finding: the wrap-both-lanes-through-_apply_cwd_notes
pattern was hand-copied at three sites (construction, MCP list_changed,
MCP disconnect), leaving the notes invariant convention-enforced. Route
all five interactive build sites through one _set_interactive_tools(
mcp_tools) helper — merge_mcp_tools with [] is a fresh copy of the
builtin base, so the no-MCP sites pass [] and the invariant becomes
structural. Coordinator branch keeps its direct build (no cwd-dependent
tools) and gains the explicit _task_tools annotation mypy now needs.
2026-07-19 19:09:58 -07:00
Patrick Buckley 8d1190d17a docs(tools): apply review round-1 findings (cwd notes)
- docs/tools.md: sync the tool-JSON metadata-keys table to _META_KEYS —
  it had drifted to 3 of 8 keys (coordinator, interactive, kind_variants
  were already missing; cwd_note/workspace_note are new).
- tests: cover the third note-rebuild trigger (_drop_mcp_surface) with a
  count==1 assertion on both lanes, and pin the deliberately uniform
  workspace_note wording across the fs tools so a one-file reword cannot
  drift the copies apart.
2026-07-19 19:09:58 -07:00
Patrick Buckley 460308241d fix(tools): lower working directory and workspace into fs tool descriptions
The process cwd was nowhere in the model's context: shells start in the
inherited process cwd (spawn_group_leader passes no cwd), relative file
paths resolve against it, but nothing told the model where it was
standing — in stock Docker every shell ran in /data while user files sat
in the /workspace mount, and the model's only recourse was to probe with
pwd (#857, #833).

Lower both facts into the tool schemas, where they gate intrinsically on
tool availability (a persona without fs tools carries no note, and
coordinator envelopes are untouched):

- tools/*.json: cwd_note/workspace_note metadata templates on bash,
  read_file, write_file, edit_file, search, diff_file; bash also states
  the fresh-shell-per-call semantics (cd does not persist) and drops a
  stale reference to the removed man tool.
- tools.apply_cwd_context(): renders the notes into descriptions;
  deep-copies noted tools (the fs dicts are shared across
  TOOLS/INTERACTIVE_TOOLS/TASK_AGENT_TOOLS and aliased through
  merge_mcp_tools), passes note-less tools through by reference.
- ChatSession._apply_cwd_notes(): wraps every fresh interactive build of
  _tools AND _task_tools (construction, MCP catalog change, MCP
  disconnect) — assignment-time, so the wire tools block stays
  byte-stable for provider prompt caches. os.getcwd() is OSError-guarded
  (MCP rebuilds run on a background thread; eval tears down its
  workdir); the workspace hint drops when the dir is missing or equals
  the cwd. Task-agent sub-agents carry their own notes via _task_tools,
  independent of parent persona visibility.
- config.get_workspace_dir(): [tools] workspace_dir with
  TURNSTONE_WORKSPACE env fallback (searxng pattern), informational
  only — no chdir, no path confinement (per-workstream working-dir
  grants are a separate planned feature).
- Dockerfile: ENV TURNSTONE_WORKSPACE=/workspace so stock deployments
  surface the mount with zero operator config.
- docs/docker.md: document the /data working directory, the
  working_dir: /workspace compose override as the operator-level fix,
  and the SQLite-fallback-DB-in-cwd caveat.

Closes #857
2026-07-19 19:09:58 -07:00
Patrick Buckley 83277a4d17 fix(console): validate project pick against rebuilt choices
The launcher project picker restored `previous` unconditionally after a
choices rebuild: a since-deleted project landed the select on a blank
selectedIndex=-1 instead of the "No project" placeholder (submit was
safe — getOptionValue returned "" — but the select looked broken).
Route it through _restorePick like the other three pickers; the
"+ New project…" sentinel stays excluded (it is a command, not a state,
and it IS in choices so validity alone would not exclude it).
2026-07-19 02:39:38 -07:00
Patrick Buckley 1d144c331b fix(ui): apply round-3 review findings (composer caches)
- ui: _paintFromCache returns its async-repaint promise;
  _paintProjectPicker routes through it (fork/hint stay bespoke) and the
  dashboard chains an Options-chip recompute on EVERY paint — an async
  repaint can drop a server-removed pick (or revert persona to its kind
  default) without firing 'change', and the chip must always name what
  submit will send
- ui/console: the false "never worse than the pre-cache behavior" claim
  replaced with the accepted-tradeoff ruling for module-load failure
  (no per-picker retry — cache-busted re-imports split-brain the cache;
  no inline-fetch fallback — that resurrects the deleted dual path)
- console: _paintHomeFromCache collapses the four verbatim
  _refreshAndPopulate* wrapper bodies; _restorePick collapses the four
  preserve-pick blocks (persona keeps its kind-default revert, now
  pinned by a test)
- models/skills: drop the consumer-less loaded/error readers from the
  modules + bridges (same omitted-not-exposed doctrine as onChange;
  projects/personas keep theirs as pre-existing public surface)
- tests: boot-order guard pins ALL FOUR data-layer module tags before
  shell.js (the boot anchor) in both index.html; wrapper/project-picker
  guards redirected to the chokepoints; chip-recompute chains asserted
2026-07-19 02:39:38 -07:00
Patrick Buckley 0d8be572c7 fix(ui): apply PR #869 review findings (turnstone + copilot)
- list_cache: null-prototype _byKey — a row keyed "__proto__" swapped the
  map's prototype via the inherited setter, and getByKey of inherited
  members ("toString", "constructor") resolved them as rows; + guard test
- list_cache: document why _pending clears BEFORE the trailing refresh
  (a .finally clear would coalesce a late force onto a stale fetch —
  declines the reviewer's .finally suggestion with the ruling in-code)
- list_cache: extra() accessor doc reflects the conditional reset;
  resetExtraOnError @param notes it is moot without extraDefaults
  (declines per-module knobs in personas/skills, which have no extra)
- ui: extract _paintFromCache — sync-mirrors-freshOnOpen /
  async-always-fresh:false now encoded once for the model/skill/persona
  wrappers and asserted at the chokepoint
- ui: replaceChildren() for the model/judge/skill picker clears
  (consistency with the persona/project populates)
- console: reword the skills fail-open comment to unambiguous past tense;
  drop the orphaned _resolveModelLabel docstring
- tests: fork-gate asserts require each paint to open its own
  `if (!_forkFromWsId)` block (the rfind+50 window false-passed a closed
  gate; the model first-gate check was vacuous; the persona gate was
  unasserted); drop one redundant `0 <=` (kept where it guards find()==-1)
2026-07-19 02:39:38 -07:00
Patrick Buckley c3beb202eb fix(ui): apply round-2 review + fix-sanity findings (composer caches)
An unprimed convergence re-review found a real login-recovery seam gap plus
cleanups (round 1's fix round manufactured one of them); fix-sanity vetted the plan.

- console onLoginSuccess recovery seam [0]+[2]: it re-warmed only skills+models
  after an in-place login; projects+personas (same pre-auth-401 gap) stayed empty
  (rail group-by-project flat, saved-coordinator raw slugs). Now force-refreshes
  ALL FOUR caches on login — force so a still-in-flight failing pre-auth fetch
  yields a trailing AUTHENTICATED refetch rather than coalescing onto the 401
  (skills/personas have no *_changed event to recover). Threads an optional
  callOpts through the four cache modules + console wrappers (backward-compatible;
  every non-console caller passes nothing).

- fork skill paint [4]: the round-1 wrapper extraction left the modal skill paint
  unconditional on a fork (wasted GET /v1/api/skills + hidden-select rebuild);
  fork-gate it like model/persona/project.

- persona wrapper [5]: extract _paintPersonaSelect so all four composer pickers
  share the sync-then-refresh wrapper instead of persona being inline-duplicated.

- dead machinery [6]: remove the zero-subscriber onModelsChange/onSkillsChange and
  the models fpExtra fingerprint fold (and the now-orphaned core fpExtra branch).
  The console repaints models via its direct models_changed handler, not a
  subscription; the fold only fed the subscriber-only fingerprint.

- O(1) modelLabel [7]: index the models cache by alias (keyField) so modelLabel is
  a getByKey, not a per-paint scan.

Declines documented in-code: forks-inherit-model [1] (deliberate) and the
fail-open cache [3] (intended, same policy as projects/personas). Deferral comment
at the ui onLoginSuccess twin (recovers on dashboard re-focus; follow-up).

Tests: rewrote the 7 guards the code changes moved (persona relocation, callOpts
threading, force, fpExtra removal) preserving their ordering intent, and added
fork-skill-gate, persona-wrapper, all-four-force, keyField, and
onModelsChange-removed coverage. 106 pass; ruff + mypy green.
2026-07-19 02:39:38 -07:00
Patrick Buckley 8d57697b2a fix(ui): apply max-effort review + fix-sanity findings (composer caches)
A max-effort review of the composer-cache branch found 3 correctness + 2 cleanup
issues; fix-sanity refined the plan before implementing.

- Modal select stickiness [0]: the reused new-ws <dialog> kept the last open's
  model/judge/skill pick and silently applied it to the next chat (sharp for a
  fork — model/judge were sent unguarded). The composer selects now render fresh
  each open (a fresh open has no `previous` selection to preserve) across ALL
  five selects, while a within-open async repaint still preserves a mid-window
  pick. The modal now shows the resolved default ("Default — gpt-5"). A fork
  INHERITS its source's model + judge (hidden + submit-gated on !_forkFromWsId,
  matching skill/persona/project).

- models default-alias reset [1]: the shared core's extra-reset-on-failure is
  now opt-in (resetExtraOnError). projects keeps it (require_project gates the
  picker, must fail open); models opts out, so a transient failure keeps the
  last-known resolved-default annotation instead of blanking it.

- models_changed coalescing race [2]: an opt-in trailing refresh in the core —
  a force caller (models_changed) awaits a refetch chained after the in-flight
  one and converges to the latest state instead of a response predating the
  change; startup/open callers stay coalesced.

- cleanups: the 4x paint-then-refresh block collapses into _paintModelSelects /
  _paintSkillSelect [6]; the "alias (model)" label centralizes into models.js
  modelLabel (registered on the window bridge) [7], deleting both local copies.

Tests: rewrote the 5 guards that pinned pre-fix literals + added fresh-matrix,
fork-inherit, bridge-registration, both-error-branch reset, and trailing-refresh
guards. 106 pass; ruff + mypy green.
2026-07-19 02:39:38 -07:00
Patrick Buckley 71b1365e7b refactor(ui): shared list-cache core + models/skills caches (no composer FOUC)
The model and skill composer pickers had no client cache: the new-ws modal, the
dashboard quick-create, and the console launcher each re-fetched /v1/api/models
and /v1/api/skills inline on every open, flashing an empty dropdown for the
round-trip even though the data was usually already in memory. Add shared caches
(models.js, skills.js) the composers read SYNCHRONOUSLY, then refresh-and-repaint
— the pattern the project/persona pickers already use.

The coalescing / fail-open refresh / change-detection / window-bridge machinery
was ~70% duplicated between projects.js and personas.js. Extract it once into
list_cache.js (makeListCache) and retrofit projects.js + personas.js onto it,
preserving their full public surface byte-for-byte (rail.js + project_creator.js
import them by name; the classic bundles read the window bridges). The
require_project advisory rides projects.js as fail-open `extra` state; personas
keep their kind-filtered choices and name->label map.

models.js carries BOTH server schemas (the node sends default_alias, the console
sends coordinator_default_alias; both send judge_default_alias) so each app reads
its own, and folds them into the fingerprint so a role-alias change still fires
onChange. skills.js returns raw rows (the ui pickers add a " [MCP]" suffix the
console omits). Selection is preserved across the sync->async repaint on every
select, including model + judge independently.

Also: the dashboard model/skill fetch-once guard is dropped (refresh-on-open now,
matching project/persona); the console re-warms models on login too (the boot
pass runs pre-auth, so the dropdown used to stay empty until a reload); and
models_changed repaints via the single refresh wrapper (no double path).
2026-07-19 02:39:38 -07:00
Patrick Buckley a23cc2c25e ci: remove claude workflows
The @claude mention responder (claude.yml) and the automatic PR review
(claude-code-review.yml) have been unreliable and are a frequent source
of CI breakage. Drop both; core CI (ci.yml, docker-publish, publish,
understone-example, vendor-js) is untouched and nothing else in the
tree references them.
2026-07-19 01:23:12 -07:00
Patrick Buckley 079257967d refactor(ui): extract _paintProjectPicker (dedupe modal/dashboard)
Review of #868 flagged the sync-paint + refresh + required/optional hint block as copy-pasted between showNewWsModal and _loadDashboardOptionsLists, already diverging structurally, so a future tweak could drift and silently re-introduce the FOUC on the missed surface. Collapse both into a shared _paintProjectPicker(sel, hint, {fork}) -- the modal passes the fork flag, the dashboard never forks. Guards re-pointed at the helper + a new one pins its sync-before-async pattern.
2026-07-18 17:36:02 -07:00
Patrick Buckley 4079542447 fix(ui): paint composer project/persona pickers from warm cache (no FOUC)
The new-workstream modal, the dashboard composer, and the console launcher
painted their project and persona <select>s only inside the async
refresh().then(...) callback, so each open flashed an empty/stale dropdown for a
network round-trip even though the client caches are already warmed at startup.
Paint synchronously from the warm cache first, then refresh-and-repaint (still
catches items created elsewhere). On a cold cache the sync paint is a no-op the
async fills, so it is never worse than before.

Both project paints reuse the same _populateProjectSelect + reconcile, so the
require_project strict-picker invariant (never auto-select a real project into a
possibly-shared one) is unchanged; persona reuses _populatePersonaSelect, which
preserves a mid-window pick and only applies the kind default when nothing valid
is selected.

Also folds in two deferred require_project polish items in the same code: the
dashboard Project label now shows the "required"/"optional" hint (parity with the
modal), and _reconcileRequiredProjectSelection reuses the projectChoices() list
its caller already built instead of recomputing it.

Models/skills selectors are a separate follow-up (no client cache today).
2026-07-18 17:36:02 -07:00
Patrick Buckley 8ce94360ae docs(projects): requireProject() advisory is safe to read synchronously 2026-07-18 16:12:59 -07:00
Patrick Buckley c019ab41d7 feat(server): opt-in server.require_project gate
Add an opt-in, default-off `server.require_project` setting. When an admin
enables it, creating an interactive chat is refused unless it is filed under a
project. The feature is inert and byte-identical when off, and can only ever
fail toward "off" (a missing config store or unset key reads as disabled).

- settings_registry: server.require_project (bool, default False, live read).
- auth: require_project_enabled + require_project_denies_create predicates
  (service scope / coordinator token_source exempt; NOT admin.coordinator),
  plus REQUIRE_PROJECT_ERROR / REQUIRE_PROJECT_CODE.
- node create gate via a declarative cfg.create_gate_require_project (wired True
  on the interactive mount only; coordinator spawns stay ungated).
- fork/resume: a fork's project is structurally its source's. Any explicit
  project_id is discarded, so a fork can never be re-filed under an unrelated
  project (which would move its copied history across a tenancy boundary).
  Inaccessible / projectless / nonexistent sources are uniform on body and
  status, so there is no cross-tenant oracle.
- console cluster-create proxy surfaces only the coded require_project 400 and
  masks every other node outcome (401/429/3xx/5xx, un-coded 400) to a sanitized
  502, guarding both body reads.
- list_projects advisory field + projects.js requireProject() (fail-open).
- fresh-create project picker requires an explicit project choice under the flag
  (no silent auto-select); forks hide the picker (inheritance is server-enforced)
  and get an accurate refusal message.
- tests: predicate matrix, resume-inheritance oracle discriminators, console
  masking, and end-to-end node-gate mount wiring.
2026-07-18 16:12:59 -07:00
Patrick Buckley cdc360bd98 fix(server): sanitize the retry closure's error display
The retry (_run) closure emitted the raw str(exc) to ui.on_error, so a
credential-bearing base-URL in a backend ConnectError
(https://user:pass@host) crossed into the dashboard SSE — the
confidentiality floor _record_fatal_error enforces, bypassed here.
Sanitize the display inline with the same sanitize_error_text redactor.

This is separable from the reused-session stale-flag hazard that keeps
_run off ensure_error_recorded: that hazard is about recording /
idempotency (deferred to #865); this is only the display string. The
double state emit and the pre-try no-persist remain in #865.

Adds a focused test that a retry-error's on_error is redacted.
Flagged by review on #866.
2026-07-17 20:08:56 -07:00
Patrick Buckley 3af80907c7 fix(server): init-message worker exits to error, not idle, on first-turn failure
The initial-message worker (_run_initial) collapsed both cancel and
backend-error exits into one `except (Exception, GenerationCancelled)`
arm that always stamped state=idle, clobbering the state=error that
session.send's _record_fatal_error had persisted+emitted. A spawned
child's first-turn backend failure (unreachable model server, exhausted
quota, auth error) therefore read as an empty, successful turn — the
coordinator's wait/inspect surface reads last_error only for
state=='error' — and the real error surfaced only after a manual nudge
re-ran the turn synchronously.

Split the arm: cancel -> idle, exception -> error. The failed child now
settles at state=error and the first wait_for_workstream returns the
enriched backend error inline. Also fixes the same latent bug for
scheduled tasks, which dispatch through the same endpoint and closure.

A failed first turn is deliberately terminal for automated wakes: it
settles to a non-ready error terminal, not the idle ready-set that
timer/watch wakes recur to, so explicit user/coordinator action
reactivates it rather than a silent auto-retry (a self-healing
wake-from-error would be a separate wake-gate change).

The exception arm routes through a new ChatSession.ensure_error_recorded:
a no-op when send already recorded the error in-line (the common
backend-boundary path — no duplicate state emit), and the recorder when a
pre-try exception (model-registry refresh, user-turn append,
system-message recompose) bypassed send's own handler, so state=error
always carries a meaningful last_error. Its idempotency guard
(_has_persisted_error) is session-lifetime, so ensure_error_recorded is
scoped to _run_initial's FRESH first-turn session only; the docstring
spells out why a session-reuse caller (retry, /send, coord send, wake)
must not route through it until the per-turn error-recorded signal of
#865 lands.

The other half of making an errored workstream cheap for a model to
handle is a stable identifier: the enriched backend error now leads with
the model ALIAS the coordinator references everywhere (list_nodes, spawn)
and annotates the backend id for the operator —
"model=DeepSeek-V4-Flash (id=deepseek-v4-flash)" — so a model routing
around a failed model correlates it against those surfaces without a
lookup, instead of burning reasoning tokens reconciling the alias against
a backend id it never sees anywhere else. Collapses to one token when the
alias and id coincide.

Tests (TestInitialWorkerFailureState) assert the coordinator-visible
manager state and the persisted last_error across the matrix — common-
backend and pre-try errors both settle error with a readable last_error;
cancel-to-idle settles idle with no error recorded. De-forks the
create-app fixture and uses the shared monotonic wait_until helper.

The completion-notification honesty surface and the error-recording
hygiene of the other send-worker closures (retry / main send / coord send
/ wake) are deferred to #865.
2026-07-17 20:08:56 -07:00
Patrick Buckley 9dea2c89f1 chore(sdk): regenerate openapi-console.json
The console OpenAPI spec had drifted from build_console_spec(): the committed
file was last generated at 1.7.0rc1 and was missing the persona and project_id
workstream-creation fields (Personas and Projects, both 1.7) plus the version
bump to 1.8.0a2. Regenerate via sdk/typescript/scripts/generate-types.py to
resync. Spec-only; no console API behavior change (openapi-server.json was
already current).
2026-07-17 11:55:47 -07:00
Patrick Buckley 515d372a14 fix(compaction): validate retry_in backoff before rendering the retry note
updateCompactionProgress coerced evt.retry_in with Number() and rendered it
unguarded, while the sibling part/total path two lines below is finiteness-
validated — a malformed backoff would render "retrying in NaNs". Validate
retry_in the same way (finite, non-negative), and keep the error text
regardless: the error is the load-bearing half of the note, so an unparseable
duration drops to "retrying (error)…" rather than suppressing the whole arm.

Addresses PR review feedback on the compaction reducer.
2026-07-17 11:28:44 -07:00
Patrick Buckley abe053f507 fix(compaction): review round 11 — settle-helper null-guard to the chokepoint
The settleSendResponse extraction left the two panes' call sites diverging on
the null-guard: interactive passed bare `data`, the coordinator passed
`data || {}` — reintroducing the copy-paste variation the shared helper existed
to erase. If a /send 2xx body were ever non-object JSON, the unknown/"ok"
fall-through would deref `data.attached_ids` and paint an already-delivered
message as a connection error; the endpoint always returns an object, so this
is a latent divergence, not a live bug.

Normalize the body once at the helper entry (`data = data || {}`) so both call
sites pass bare `data` and stay byte-identical, and every internal deref plus
any future caller is covered by the single chokepoint. The node settle-harness
gains a null-body case — red without the fix, since the call-arg evaluation
throws before the stub runs.
2026-07-17 11:28:44 -07:00
Patrick Buckley 1e86f068cd chore(compaction): review round 10 — cleanups from the first correctness-clean round
- INTERJECTION_CAP_CHARS joins PENDING_SENDS_MAX in workstream.py: the
  2000-char interjection cap was triplicated (queue_message's truncation,
  the defer-fidelity refusal, the test fake) and already drifting in
  measurement — the defer check deliberately measures RAW text (raw >=
  cleaned since parse_priority only strips, so it can only over-refuse
  into a full-fidelity fresh spawn, never admit a truncation), now
  stated in a comment. The four unrelated 2000s (notify tool, recall
  preview, summary formatting, agent step cap) stay deliberately
  unlinked — they are different contracts.
- The changelog's ~110-line compaction bullet is split into six per-seam
  bullets matching house style, and the Breaking (1.8) compaction-event
  notice moved under "### Changed" where integrators scanning bullet
  heads will actually see it (cross-referenced both ways with the
  pre-1.8 embedder compat bullet).
- SpawnMetricsHook takes (ui) only: the request parameter was threaded
  through the whole dispatch-attempt path solely to be ignored by both
  installed impls; the stale "coord wires None" claims in the rewritten
  comment blocks are corrected too.
- The attachments tests' Mock-hardening block lives once in
  _harden_ws_mock() — deliberately excluding _worker_running, which each
  fixture chooses per scenario (one relies on the truthy auto-Mock).
- Two hand-rolled poll loops become wait_until (file convention,
  diagnostic timeout) and the orphaned time import goes with them.
2026-07-17 11:28:44 -07:00
Patrick Buckley d280db514e fix(compaction): review round 9 — drain-exit ownership, missed-edge settle, pre-turn hook guard
Three point-guards from the ceiling round (no primitive took a hit;
correctness yield halved at identical review sensitivity):

- The drain's clean-exit wake moved OUT of the function-level try: it
  runs after the drain has already retired its slot, so a raise out of
  the wake (the dispatcher re-raises Thread.start failures) could reach
  the last-resort handler and clear a slot this thread no longer owned —
  nulling a successor drain's live registration and letting two drains
  service one list. The wake now runs post-try under its own guard
  (mirroring _retry_pending_wake), only on the clean-exit path, and the
  last-resort slot-clear is identity-guarded like every sibling exit
  seam. The except arm needed a function-local threading import: the
  module-top import is TYPE_CHECKING-only, so the guard would have
  NameErrored inside the handler with strict mypy fully green.
- The shared settle helper promotes a non-deferred chip that binds onto
  an already-idle pane: its only sweep fired mid-POST (unbound then) and
  no message_dispatched ever comes for non-deferred sends, so the chip
  stayed a permanently retractable "queued" bubble for a delivered
  message. Keyed on post-bind chip state (also catching a raced folded
  settle bind just reconciled) and skipping dismiss-in-flight chips —
  the sweep's own aria-busy discipline. Pinned behaviorally: the helper
  now executes under node (a 4-row missed-edge matrix), possible since
  the consumer-less window bridge is gone.
- _claim_generation's on_generation_claimed emission is call-guarded:
  it sits on send()'s pre-turn path, before the user turn is appended
  and before the fatal handler's coverage, so a raising override
  degrades to a lost latch-break instead of silently dropping every
  user message on that session.

Cleanups: /command's transport catch and status-less non-2xx bodies are
loud now (threading {ok, status} through the parse — deliberately no
throw-on-!ok pre-gate, since the busy and error arms ride 409/503);
PENDING_SENDS_MAX lives in workstream.py and ChatSession._QUEUE_MAX
aliases it (one backpressure bound, structurally incapable of
diverging); the send handler's not-ok arm uses _queue_full_response();
the dead window.createQueueController bridge is deleted and the file
header's consumer map corrected.
2026-07-17 11:28:44 -07:00
Patrick Buckley 1224b02d03 fix(compaction): review round 8 — seam obligations become primitives
Eight rounds of findings against the defer-and-drain seam shared one
generator: N sites each hand-copying M obligations (spawn discipline,
the order-barrier pair, backpressure, best-effort emission, the client
settle matrix), with every review finding an empty (site x obligation)
cell. This round makes each obligation a single primitive:

- The order barrier is Workstream.send_barrier_active() — one
  definition of the two-term pair (pending entries OR drain alive),
  consulted by the /send route, the coordinator adapter, and the
  queued-nudge wake gate, which previously carried only the list term
  and let a synthetic wake jump an acknowledged send during the
  claimed-entry window. _PendingSend moved to workstream.py beside the
  invariant that justifies the drain-alive term; the pending fields got
  precise types and worker_kind became a Literal, so a typo'd
  "command" comparison is now a type error instead of a silently
  never-firing defer guard.
- _defer_send probes the barrier before constructing anything, bounds
  acceptance at 10 pending (the interjection queue's own backpressure
  contract — unbounded acceptance pinned message + attachment bytes
  per entry for a whole command window and then ran one unattended
  turn each), and spawns the drain with rollback: a Thread.start
  failure pops the just-accepted entry and answers the retryable
  queue_full instead of 500ing after registration (a phantom the
  client could neither see nor retract, dispatched later as duplicate
  turns). start() deliberately stays inside the lock, unlike
  session_worker's outside-lock discipline: this slot is
  is_alive()-gated, false for a constructed-but-unstarted thread, so
  an outside-lock start would open a double-drain window.
- A /command whose worker never spawned answers 503
  {"status": "error"} (spec + docs + a pane error arm) instead of the
  generic 200 ok that told SDK callers their /clear ran.
- The compaction lifecycle emitter is raise-proof at its single
  dispatch tail: a raising duck-typed hook degrades to a lost render,
  never a lost end event — previously a raising on_error or a raising
  failed-end emit left every pane a frozen progress bar, and a raising
  SUCCESS end after the committed swap fabricated a failed end.
- The client settle matrix lives once: composer_queue's
  settleSendResponse owns every /send response arm for both panes
  (the near-verbatim twins were already drifting), parsePriority is
  shared, and the busy stamp is centralized in setBusy(b, source) with
  "server" as the fail-safe default. Deferred sends release the
  composer (no worker exists for them; retracting the chip no longer
  strands the pane in Stop mode), queue_full on an idle-looking pane
  removes the optimistic bubble and restores busy (the refusal can now
  fire with no worker and no drain to ever emit a state event), and
  the pre-bind settle buffer is TTL-based — a burst of deferred
  dispatches parked this tab's own raced settle first, where the old
  size cap evicted exactly it.
- The command backstop / console proxy timeout inequality is enforced
  by a test importing both named constants (both proxy_client
  constructions, startup and the mTLS re-create); the compaction card
  wears blue (magenta is reserved for the MCP surface); the redundant
  TerminalUI.on_compaction override is gone (the inherited protocol
  default is the policy site).
2026-07-17 11:28:44 -07:00
Patrick Buckley 5511ab9a35 fix(session-worker): release the slot claim when Thread.start itself fails
If thread creation raised (thread exhaustion, MemoryError), the
dispatcher had already claimed the worker slot under ws._lock — but the
flag's only clearer is _runner's finally, on a thread that never
started. The workstream then looked idle forever (no state change ever
fired) while every subsequent dispatch took the reuse path into a queue
no worker would drain, until an operator force-cancel.

Roll the claim back under the lock (identity-guarded, like _runner's
own clear, so a concurrent force-cancel's successor is never clobbered)
and re-raise. Re-raise rather than return False: callers' crash paths —
the deferred-send drain's per-iteration handler with its backoff — are
shaped for exceptions, and a False would masquerade as queue-full
backpressure and mislabel the wake gate's refusal log. worker_kind is
left stale, as documented (every reader conjoins _worker_running).

Affected every dispatch path: sends, wakes, retries, the deferred-send
drain, and workstream init.
2026-07-17 11:28:44 -07:00
Patrick Buckley fd5d3efb43 fix(compaction): review round 7 — drain crash/order/settle rows, protocol-default fallback, bool event-id guard
Completes the defer-and-drain seam against the matrix rows round 6 never
enumerated (the defer contract itself took no hits):

- Crash row: a claimed entry survives a dispatch crash — the
  per-iteration handler re-inserts it at head (claim-flagged so a
  claim-section failure can't duplicate it), backs off ~1s, retries.
  The last-resort handler spawns no successor (Thread.start fails under
  the exact exhaustion that reaches it): the route's ensure-drain stays
  the single spawn site, so single-flight is structural and a dead
  drain revives on the next defer.
- Order row: the pending list is the order authority. The /send route
  pre-checks pending/drain-alive under the same lock acquisition that
  appends (one _defer_send helper serves the barrier and command-window
  triggers); the coordinator adapter refuses via its return value; the
  queued-nudge wake gate yields to pending sends and is re-armed by the
  drain's clean exit — which covers lists emptied by pure retraction —
  as well as every deferred turn's exit; retry-after-rewind is a
  documented accepted overtake; init/create is fresh-ws-by-construction.
- Client settle row: queued responses carry "deferred": true
  (SendResponse + regenerated openapi-server.json, status enumeration
  completed); bind(el, msgId, {deferred, attachedCount}) replaces the
  _deferredAttachments expando; the idle sweep skips deferred and
  unbound chips; the shared dispatch attempt emits pane-tier
  message_dispatched (folded: true for interjection fold-ins — the chip
  clears only its deferred flag and keeps a live x while DELETE still
  genuinely retracts); settles that beat bind() park in a bounded
  buffer; an idle-thinking pane retro-converts its optimistic bubble
  into a real queued chip instead of presenting a parked message as
  sent. Rejection polling waits on the slot flags — one dispatch
  attempt per slot-state change, not 4 Hz.
- SessionUI.on_compaction's protocol stub became a real default body
  (the classic on_info rendering): explicit subclasses inherit protocol
  members as real methods, which defeated _compaction_event's getattr
  fallback for exactly the pre-1.8 embedders it serves.
- _coerce_event_id() rejects bools (isinstance(True, int) is True) at
  all three duck-typed event-id coercions: the compaction marker stamp,
  on_system_turn's persisted return, and _ui_event_id.
- Quick-command backstop 60s -> 25s, under the console proxy's 30s so
  the degraded "running" answer can traverse a proxied pane (which now
  surfaces it); /resume docs drop the fictional history SSE event
  (clear_ui + REST re-fetch is the contract); /send response docs match
  the wire.
2026-07-17 11:28:44 -07:00
Patrick Buckley e99673eb0c fix(compaction): review round 6 — defer-and-drain send windows, workstream-scoped notify, ERROR badge survives /compact
Replace park-and-abandon /send semantics with defer-and-drain: a send
landing in a command window is answered {status: queued, msg_id}
immediately and dispatched full-fidelity by a per-workstream drain
thread when the window closes. Parking encoded client disconnect as
message retraction — true only for the composer's ✕-abort; every
bounded caller (coordinator client and console proxy at timeout=30,
SDKs, stock proxies) timed out and lost its message for the whole
window, and the compensating client machinery was racy (one-shot
sendAbortMs sample) and over-broad (_sendAbort fired on the
interjection path, dispatching dismissed messages while showing a
connection error). Dismissal is now uniformly bind() → DELETE, with a
fall-through that retracts pending entries; retracting an
attachment-bearing deferred send surfaces the discarded-attachments
consequence. The drain claims entries under ws._lock immediately
before dispatch (DELETE can never remove an in-flight message),
refuses the truncating interjection fallback for oversized or
attachment entries atomically inside the enqueue callback, and never
gives up while the workstream lives; durability is documented as
node-local at-most-once. sendAbortMs, _sendAbort, the 600s bound and
the park loop are deleted; route and drain share one dispatch
implementation (spawn metrics included).

Also: the initial-send completion notify is un-gated from slot
ownership (_fire_notify_targets has exactly one call site — successor
turns never notify, so the round-5 guard prevented a duplicate that
cannot exist while converting force-cancel into permanent notification
loss for scheduled workstreams); /compact on an ERROR workstream
restores the badge instead of stamping idle over it; duck-typed
SessionUIs without on_compaction get the classic on_info lines back
via a shared renderer (superseded OK ends included — a committed swap
must never be silent; pre-1.8 SSE clients are deliberately not
dual-emitted, documented as a 1.8 breaking change); failed-end notice
suppression is computed once by the emitter as a notice bool on the
end event (SDK py+ts), replacing the hand-synced cli/JS policy while
the panes keep their pane-local card-ownership clause.
2026-07-17 11:28:44 -07:00
Patrick Buckley 1dbf7f410c feat(compaction): lifecycle events, web progress card, history re-render
Compaction becomes visible: a first-class 'compaction' SSE lifecycle
(start/progress/end, compaction_id-correlated, superseded-flagged ends)
replaces the loose info lines; both web panes render a progress-bar card
that settles into a persistent result card, re-rendered after reload via
the /history projection of the compaction marker row. Slash commands echo
as command chips instead of fake user turns.

The enabling rework: /command dispatches onto the workstream worker slot
(the old inline path blocked the node's event loop for whole compactions
and let /clear interleave with live turns). Busy refusals answer 409;
quick commands are awaited loop-natively with a 60s backstop; /compact is
fire-and-forget. Sends during a command window park in the /send route
and dispatch full-fidelity afterwards — the interjection queue (length
cap, cross-user guard, identity-swap hazards) is unreachable there — with
a compaction-aware client abort bound shared by both panes. compact_now()
carries send()'s full generation discipline; Stop aborts the in-flight
summary HTTP stream via a generation-scoped cancel ref; force-abandoned
compactions retire at their next checkpoint and their stragglers are
fenced off every surface (panes, pill latch, CLI). Every session retry
backoff is cancel-aware via one shared helper. Docs, OpenAPI spec, and
both SDKs updated.

Verified: 9457-test non-live suite, JS pin suites, headless-Chrome
reducer harness; five unprimed multi-agent review rounds (correctness
trend 15/6/6/4/4) with plan-level design passes on every fix round.
2026-07-17 11:28:44 -07:00
Sanjay Santhanam 82676080a4 fix(session): describe skill selections accurately
Use neutral "set" wording for operator skill markers so re-selecting the
current skill does not falsely claim a change. Update the regression
expectation for the persisted marker.
2026-07-17 00:58:37 -07:00
Sanjay Santhanam a64cd25807 fix(session): record operator skill changes
Operator-driven /skill changes were only shown in the UI, leaving no trajectory marker for the model. Persist a system turn for named skill changes and clears, with regression coverage for both paths.
2026-07-17 00:58:37 -07:00
renovate[bot] 8240d2c00d chore(deps): update helm release postgresql to ~18.8.0 2026-07-16 10:51:18 -07:00
renovate[bot] 8f8c2f4ca3 chore(deps): update github actions 2026-07-16 07:06:06 -07:00
renovate[bot] 7b1f77dda7 chore(deps): lock file maintenance 2026-07-15 21:07:39 -07:00
renovate[bot] 72229cac26 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.29 2026-07-15 21:07:15 -07:00
renovate[bot] 09c5475b0e chore(deps): update actions/setup-node action to v7 2026-07-15 21:06:48 -07:00
Patrick Buckley 0e6a99e0f1 chore: bump version to 1.8.0a2 2026-07-14 11:43:44 -07:00
Patrick Buckley 84577ee530 fix(mcp): PR #844 review — correct success docstring, back out the dead admin pill
Copilot review feedback (all three valid):

- _record_refresh_success docstring still claimed the push-driven
  single-kind refresh calls it — round 8 deliberately stopped that (a
  single kind can't declare a server-scoped 'ok'). Docstring now states
  the full-pass-only contract and points at the push path's _record
  closure for why.

- The admin refresh pill's skipped-tint logic (admin.js) and its
  .mcp-refresh-pill-skip CSS were dead code: /v1/api/_internal/mcp-status
  strips last_refresh_at/last_refresh_outcome via the read-scope
  projection, so admin.js never sets newestRefreshAt and the pill block
  never runs. Backed both out; the whole pill fix (whitelist the fields
  with a read-scope-coarsened outcome, THEN the color logic + CSS) now
  lives in #843. The CHANGELOG's false 'the admin console's refresh pill
  paints…' claim is dropped — the /mcp refresh CLI and the 202-skipped
  endpoint (which read last_refresh_outcome directly, not via the strip)
  still work and remain documented.

The 5 github-code-quality 'statement has no effect' comments are the
known PR #840 false-positive class (the scanner reads 'await <name>' as
a valueless expression); each flagged await is load-bearing (drains a
parked runner so the next assertion is non-vacuous, delivers a
cancellation, or awaits a _noop to fabricate a done owner_task) — no
code change.

Refs #839, #843
2026-07-14 11:39:25 -07:00
Patrick Buckley 80e7b9e9ca fix(mcp): review round 8 — push-success can't declare health, first-notify never debounced
- A single-kind push SUCCESS no longer clears the server error pill or
  stamps 'ok': _last_error / _last_refresh are server-scoped but a push
  refreshes only ONE kind, so a tools-failing server must not go green
  because its prompts push succeeded (a wrong-healthy window, bounded by
  the health tick — but a real 200-OK lie). Only a full pass declares
  'ok'; the failure's armed health-tick retry runs it. This reverts the
  over-reach of round 7's push-success outcome write (a self-inflicted
  regression) — net simpler.
- The (server, kind) debounce uses a None sentinel, not a 0.0 default:
  time.monotonic() counts from boot, so on a node whose process started
  < _NOTIFICATION_DEBOUNCE (5s) after boot, the 0.0 compare would debounce
  the VERY FIRST push — dropped with no recovery on the pool path. Absent
  stamp = never refreshed = always admit.
- _record_refresh_skipped completes the outcome-helper set: the three
  inline 'skipped' stamps now share one config-gated helper (with
  _record_refresh_success / _record_refresh_failure), and the
  reconnect-success branch routes through _record_refresh_success — no
  more hand-copied gates to drift.
- The per-message refreshers dict + on_debounce_drop closure are built
  ONCE per handler (both static and pool), not on every server->client
  message before the isinstance/debounce/coalesce early-returns.

Accepted (documented): an operator /mcp refresh that finds the connect
lock busy skips + arms the retry rather than waiting (waiting
re-introduces the refresh-budget exhaustion busy-skip exists to prevent).
4 findings refuted. Suite 9408 green.

Refs #839
2026-07-14 11:39:25 -07:00
Patrick Buckley 52dcb6a47b fix(mcp): review round 7 — consolidate the refresh-outcome write path
All three round-7 findings shared one root cause: last_refresh_outcome
(the single source of truth for the CLI / endpoint / admin pill) was
written inconsistently — ungated writes scattered across _refresh_server
and _refresh_all, never written by the push path, never popped on
removal. Consolidate every static outcome write through two config-gated
helpers so the invariant holds: _last_refresh[name] exists IFF the
server is configured and has a real outcome.

- _record_refresh_failure now stamps the (config-gated) error:<Class>
  outcome; _record_refresh_success is its twin (gated ok stamp + pill
  clear). The ungated writes inside _refresh_server (both the internal
  error write and the success write) and _refresh_all's except are
  removed — routed through the helpers. A failure observed for a
  just-removed server no longer leaves a permanent stale error: row.
- The push-driven refresh path (_run_static_notification_refresh._record)
  now records the outcome on BOTH success and failure, not just the
  error pill — a green 'ok' outcome no longer persists under a red error
  row after a push fails, and a successful push clears a prior error.
- remove_server_sync pops _last_refresh (via _clear_static_push_state
  markers=True); a session drop KEEPS it (the outcome persists across a
  reconnect — only removal clears it). The removed-mid-pass branch drops
  any stale row too, so last_refresh_outcome doesn't report a departed
  server's prior 'ok'.

_reap_bounded's pending-task concern was reviewed and REFUTED (a
pending child on external cancel during shutdown is correctly left to
loop teardown). Declined the per-notification refreshers-dict
allocation cleanup: trivial (a 3-entry dict on a rare debounced path),
and the late binding is deliberate for test overrides + mypy attribute
checks.

Tests: push-refresh success/failure write the outcome, removal pops it,
session drop keeps it, failure for a removed server leaves no stale
row; the 3 TestLastRefreshTracking tests updated to the split contract
(_refresh_server propagates, the caller records). Suite 9407 green.

Refs #839
2026-07-14 11:39:25 -07:00
Patrick Buckley 86aeb43120 fix(mcp): review round 6 — close the refresh-outcome reporting residuals
Three residual gaps in the round-5 skip-outcome threading, all in
_refresh_all's other reconnect branches plus the endpoint ordering:

- The disconnected-server reconnect DEFERRAL (_ensure_static_connected
  returns None: a sibling call in flight on the old stack, lock not
  held) returned None without stamping 'skipped', so the endpoint and
  pill read the STALE prior 'ok' and reported a never-run refresh as
  current. Now stamps 'skipped' like every other skip branch.
- A server removed from config between the top-of-loop session check
  and the cfg lookup fell through to  with results[name]
  UNSET, omitting it from the returned dict — an operator refreshing
  that one server saw a bare 'refresh complete' with no line. Now
  reports None so it renders.
- internal_mcp_refresh_one checked 'skipped' BEFORE the error pill, so
  a skip on a server carrying a live error returned a benign 202
  instead of 500 — a status-code-keyed caller would treat an erroring
  server as healthy-but-busy. Error is now checked first.
- _reap_bounded swallowed an external CancelledError (shutdown / an
  operator cancel of the refresh runner) — it now re-raises after a
  best-effort exception retrieval, honouring the cancel. Dropped the
  unneeded asyncio.shield in the process.

Tests: deferral stamps skipped, removed-mid-pass reported not omitted,
endpoint error-beats-skip → 500, reap re-raises external cancel. Suite
9403 green.

Refs #839
2026-07-14 11:39:25 -07:00
Patrick Buckley 748f670fe8 fix(mcp): review round 5 — thread the refresh outcome to every operator surface
The 'skipped'/None refresh sentinel added in round 4 was only half
threaded: consumers still misreported it. Unify all operator surfaces
on ONE source of truth — the per-server last_refresh_outcome ('ok' /
'skipped' / 'error:<Class>') — exposed via a new last_refresh_outcome()
accessor:

- _refresh_all returns None (not ([], [])) for a FAILURE too, so a
  failed refresh is never rendered as 'no changes' (the pre-#839 lie
  the sentinel exists to close); None is disambiguated skipped-vs-failed
  by the outcome. ([], []) now strictly means 'ran, no changes'.
- /mcp refresh renders skip ('skipped — retry scheduled') and failure
  ('refresh failed (error:X)') distinctly from 'no changes'.
- The node-internal refresh endpoint returns 202 'skipped' instead of a
  misleading 200 'ok' for a refresh that never ran (the busy-lock skip);
  it reads the outcome from the manager accessor because the public
  status projection deliberately whitelists last_refresh_outcome out.
- admin.js paints 'skipped' with a neutral info pill
  (.mcp-refresh-pill-skip), not the error-red any-non-'ok' used to get.
- _admit_list_changed rolls back BOTH the coalesce marker and the
  debounce stamp when scheduling raises, so a same-kind push in the
  window afterward isn't debounced against a refresh that never spawned
  (the pool path has no on_debounce_drop recovery).

Tests: endpoint 202-skip, CLI skip/failure render, _refresh_all
failure→None + outcome, spawn-failure stamp+marker rollback. Suite
9399 green.

NOTE filed #843: the admin refresh pill's data (last_refresh_at/outcome)
is stripped by BOTH status projections and never reaches admin.js — a
pre-existing latent bug (the pill has never rendered); the admin.js
color fix here is correct-when-reachable. Out of #839 scope (the read
projection strips it for a privacy reason that needs its own coarsening
decision).

Refs #839
2026-07-14 11:39:25 -07:00
Patrick Buckley 1a80466369 fix(mcp): review round 4 — removal/reconcile lifecycle, honest skip reporting, same-kind debounce recovery
reconcile_sync no longer abandons a DB-driven removal that timed out:
both the removal loop and the config-update loop keep the name in
_db_managed (and skip the follow-on add) when remove_server_sync
returns its mutated-nothing False, so the next pass retries instead of
the deleted/reconfigured server serving stale tools until restart.

remove_server_sync is now cancel-safe end to end: it FORCE-drops the
session before queueing (parked push runners bail at their session
gate instead of serializing ≤30s list calls ahead of the removal —
the noisy #839 server was exactly the one whose runners could starve
its own removal), and wraps the post-lock cleanup in try/finally so a
caller-timeout cancel landing mid-teardown still completes the state
pop, catalog rebuild, and lock retirement rather than stranding a
config-gone ghost catalog. Config survives a park-cancel, so the
health loop recovers it.

_refresh_all reports None (not a fake ([], [])) for a busy-skip or
supersede, stamps a 'skipped' status row, and /mcp refresh renders it
distinctly — the operator is no longer told a never-refreshed server
is current. A same-kind push lost to the debounce window (the prior
runner already finished; the server won't re-announce) arms the
health-tick retry, closing the one staleness hole the per-kind
debounce still had; a push covered by a queued runner does not arm
(no lost change). Static resource/prompt catalogs are capped at
connect discovery and every refresh. _list_resource_pair's reap is
bounded so a future SDK cancel-regression can't wedge the lock.

Cleanups: _arm_refresh_retry (retry-arm gate, ×3), _spawn_full_refresh
(discard+spawn, ×3), _popen_mcp_server (live-server spawn, ×2), the
tautological stamp-arithmetic TestNotificationDebounce deleted. Suite
9395 green.

Refs #839
2026-07-14 11:39:25 -07:00
Patrick Buckley 53f11454ad fix(mcp): review round 3 — cap static catalogs, atomic removal, unify the list_changed protocol twins
- Static resource/prompt catalogs are now size-capped at connect
  discovery AND on every refresh (mirrors the pool twins and the static
  tools path): a misbehaving server's push ran uncapped through the new
  spawned refresh path and could balloon the shared node's merged
  catalogs on every notification.
- remove_server_sync mutates NOTHING outside the per-name lock: the
  up-front config pop meant a removal cancelled while parked (behind
  the push-refresh runners that now share this lock) left a
  half-removed server — config gone, session and published catalogs
  alive, no driver able to reconnect or cleanly re-remove. A timed-out
  removal is now honestly retryable.
- _refresh_all's DISCONNECTED branch busy-skips too (parking inside
  _ensure_static_connected burned the pass's 30s budget on one
  mid-reconnect server), and a busy-skip on either branch ARMS the
  health-tick retry — an operator-requested refresh can no longer be
  silently dropped with output indistinguishable from 'no changes'.
- reconnect_sync drops the session before queueing on the lock (FORCE
  semantics already rebuilt live sessions): parked push runners bail
  at their session gate instead of serializing up to one 30s list call
  per kind ahead of the operator's recovery action. Residual: one
  mid-list holder can still precede the 45s attempt; a timed-out
  reconnect is honest and retryable.
- _refresh_server's supersede check gains the session arm: a spawned
  retry/post-reconnect pass racing an eviction skipped instead of
  manufacturing a false 'not connected' error pill (and a re-arm loop)
  for a self-healing condition.
- The list_changed protocol twins are UNIFIED (Closes #842): the
  admission half (_admit_list_changed) and the runner half
  (_run_list_changed_refresh) each exist once as plain parametrized
  methods — values and small closures, no factory layer (mcp v2 drops
  the factory pattern; the two thin message_handler closures remain
  only as SDK-v1 bindings). The one true asymmetry — coalesce-marker
  ownership on the superseded path — is a documented boolean: pool
  markers are only ever cleared by their runner; static markers are
  cleared by remove_server_sync, so a present marker belongs to the
  re-added generation. Both runners keep their names and signatures;
  the notification suites pass unchanged.
- Cleanups: per-kind staleness rechecks stripped from the static
  refreshers (unreachable under the lock discipline — the MUST-hold-
  lock contract is documented instead); _run_hl (5th run-on-loop copy)
  replaced at 44 call sites; _poll_until centralizes the live-test
  wait loops; docs no longer describe the periodic refresh tier
  removed in eb2a119d.

Refs #839
2026-07-14 11:39:25 -07:00
Patrick Buckley f8f191686f fix(mcp): review round 2 — busy-skip the refresh pass, fail-fast list pairs, health-tick refresh retry
- _refresh_server never parks on a held connect lock: the holder is
  itself a catalog publisher whose publish supersedes the pass, and
  parking burned refresh_sync's whole 30s budget on ONE busy server (a
  reconnect attempt holds the lock up to 45s), failing the operator
  pass for every healthy server queued behind it. Busy → skip (None),
  no publish, no status writes; the identity/state recheck stays as
  belt-and-braces for the one-tick check→acquire race.
- _list_resource_pair: the ONE copy of the paired resources/templates
  list protocol (both twins). Fail-fast — a fast real error (auth /
  method rejection) surfaces as ITSELF instead of being masked behind
  a hung sibling's eventual 30s TimeoutError — with the survivor
  CANCELLED and REAPED inside the timeout scope, never left detached
  on the shared session.
- Health-tick refresh retry: there is NO periodic refresh pass
  (removed in eb2a119d; the docs still claimed the 4h tier — fixed),
  so a push refresh that failed while the transport stayed up had no
  automatic recovery and the shared catalog stayed stale for every
  user until an operator intervened. Failures and busy-skips arm
  _static_refresh_retry via the shared recorder; the health tick
  drains it with one bounded, lock-serialized full pass per tick;
  success, session drops, removal, and the post-reconnect spawns
  clear it. This also un-latches the error pill: the retry's
  completion clears it within a tick.
- _record_refresh_failure: the bearer-redaction policy (type +
  message, never exc_info) lives exactly once; all three
  refresh-failure sites route through it.
- Static runner discards its coalesce marker only AFTER the
  lock-identity check: on the superseded path a marker present in the
  set belongs to the re-added generation's parked runner, and
  discarding it would mint duplicates past the one-parked-runner
  bound (the pool runner deliberately differs — nothing else clears
  pool markers, so its marker is its own to release).
- _clear_static_push_state: the ONE (server, kind) keyspace walk for
  stamps + retry flag (+ markers on removal).
- Tests: busy-skip, superseded-no-status, fail-fast + reap (<5s
  bound), retry arm/drain/re-arm/clear quartet, logged-wrapper
  contract updated to the shared recorder's arg shape; vacuous
  stamp-math test deleted (behavioral per-kind coverage retained);
  _free_port/_wait_tcp_ready/_wait_session_live hoisted to conftest
  for both live tests.

Refs #839
2026-07-14 11:39:25 -07:00
Patrick Buckley aefcf53405 fix(mcp): review round 1 — supersede retired-lock refreshes, per-kind debounce, complete gather pairs
- _refresh_server: post-acquire lock-identity + state-existence recheck;
  a pass superseded by remove (or remove + re-add) returns None and
  writes NO status — it must not run its list calls as a second,
  unserialized publisher against the re-add's discovery wiring,
  resurrect status rows for a removed server, or stamp a false "ok"
  over a generation it never refreshed. _refresh_all treats None as a
  deliberate skip (no breaker success record).
- Debounce stamps are per (server, kind) on BOTH paths: refreshes are
  kind-scoped, so a server-scoped stamp dropped a different-kind
  notification inside the window outright — a tools push swallowed the
  prompts push 100ms behind it, and nothing observed the prompt change
  until the server pushed that kind again. Teardown pops loop the
  kinds; remove_server_sync also discards the server's coalesce
  markers so a parked old-generation runner's marker cannot coalesce
  away a re-added server's first push.
- Resource refreshers (static + pool) gather with
  return_exceptions=True: fail-fast gather left the surviving list
  call running detached — outside the timeout scope and the lock
  serialization — as an unbounded in-flight request on the shared
  session.
- Spawned post-reconnect refreshes route through _refresh_server_logged:
  the re-raise escaped into _spawn_background's done-callback, whose
  exc_info log serializes the chained httpx.Request carrying the
  configured bearer for auth_type=static servers; _refresh_all's
  except drops exc_info for the same reason. Failure diagnostics widen
  to "Type: message" in logs and the error pill — the message text is
  header-free; only the serialized chain leaks.
- Accepted + documented: connect-lock contention on dispatch
  reconnects is bounded to one in-flight list call (parked runners
  bail instantly post-eviction); the error pill persists until the
  next COMPLETED refresh (a notification's arrival proves nothing
  about whether the failure resolved).
- Tests: per-kind debounce independence, superseded-pass writes
  nothing, gather-sibling completion, logged-wrapper swallow with the
  exc_info channel asserted SILENT, remove clears markers;
  _run_on_loop/_drain_background hoisted to conftest (4 drifted
  copies); proc.kill() portability in the live push test.

Runner-twin dedup (static/pool protocol duplication) deferred to #842.

Refs #839
2026-07-14 11:39:25 -07:00
Patrick Buckley 37144991c9 fix(mcp): spawn static list_changed refreshes off the receive loop
The static-path notification handler awaited its catalog refresh inline
in the SDK's receive loop, but the refresh issues a request on the same
session — a request whose response only that (now parked) loop could
route. The refresh never completed, and every user's calls on the
shared per-node session stalled behind it, unbounded, until the health
loop's ping timeout tore the transport down — which was also the only
way a pushed catalog change ever landed. Port of the pool-path protocol
(#836) onto the static primitives:

- Refreshes are debounce-gated, coalesced per (server, kind), and
  spawned as tracked tasks; the runner serializes on the per-name
  connect lock so a refresh, a connect's discovery wiring, and the
  manual/periodic _refresh_server pass can never publish out of order
  (the remove -> re-add race is closed by lock identity, the static
  twin of the pool's entry-identity check).
- The coalesce marker is cleared at lock-acquire so a change the
  in-flight list missed spawns exactly one successor; the finally
  discard is gated on non-acquisition so it never clobbers that
  successor's marker.
- The debounce stamp survives a failed refresh (throttle over lost
  window) and every teardown/eviction path now pops it via the paired
  _drop_static_session_and_stamp, so a reconnected transport's first
  notification refreshes immediately.
- All three static list calls are bounded by _CONNECT_TIMEOUT and
  discard their result if the state entry was replaced mid-flight;
  the resource pair rides one gather (mirrors the pool sibling).
- Failure logging is (Exception, BaseExceptionGroup) type-name-only:
  an escaping group reaches _spawn_background's exc_info log, which
  serializes the chained httpx request carrying the configured bearer
  for auth_type=static servers; the recorded operator error string is
  type-name-only for the same reason. Non-list-changed notifications
  no longer clear the server's error pill (that pop was accidental —
  only a completed refresh proves anything).

Includes a live end-to-end repro (FastMCP subprocess pushing
tools/list_changed through a real receive loop): pre-fix the triggering
call itself deadlocks (verified against main), post-fix it completes
with the catalog landing on the original session, no teardown.

Closes #839
2026-07-14 11:39:25 -07:00
Patrick Buckley b2f53d329b chore(ci): drop review-event triggers from claude.yml
Bot PR reviews (Copilot, code-quality) fired pull_request_review and
pull_request_review_comment runs that always gate out but pile up as
awaiting-approval clutter. @claude stays invocable via issue and PR
conversation comments, the only path actually used.
2026-07-13 23:35:15 -07:00
Patrick Buckley 6f991d6aff chore: bump version to 1.8.0a1 2026-07-13 22:41:36 -07:00
Patrick Buckley 7d4d76e097 fix(providers): PR review — orphan deltas arm the finish shim, test style
- Orphan argument deltas count as delivered output for the
  finish_reason_optional shim, exactly as they count as a streamed
  signal for the terminal harvest: a lax Responses server that never
  announces items AND never sends a terminal event still delivered its
  tool call — with the tolerance declared that is a completion, not an
  IncompleteStreamError. (Review caught the shim/harvest inconsistency
  the round-9 fix introduced.)
- Test style: single import style for the model_turn module, assert on
  a local instead of a call expression, drop a pass-through lambda.
2026-07-13 22:39:19 -07:00
Patrick Buckley 747177a76c fix(providers): review round 9 — orphan/harvest collision, shared shim gate, retired-id rationale
Correctness:
- Responses: orphan argument deltas (streamed without any
  output_item.added) now count as a streamed tool-call signal, so the
  terminal harvest stands down instead of re-emitting the same call
  onto the same slot — the reproduced collision concatenated the
  arguments JSON into an unparseable double copy.

Cleanup / documentation:
- finish_shim_due in _protocol is THE gate for the lax-server finish
  shim — one predicate (and one definition of 'delivered output') for
  all three adapter families, so the same capability flag cannot
  acquire per-family completion semantics.
- The Responses error/response.failed branches share one failure tail
  (only code/message extraction differs) — the same server failure can
  never become retryable through one event type and fatal through the
  other, pre- or post-terminal.
- _format_refusal pins the refusal rendering the streamed event and
  the terminal harvest both use.
- The capability-table floor comment and CHANGELOG Removed entry now
  state the real rationale: OpenAI has RETIRED the pruned ids from the
  API — the rows described unreachable contracts, not unpopular ones.
- CHANGELOG names the stream-entitlement break class (verified-org
  streaming, pre-stream_options gateway api-versions) with its
  serving-side remediation; deliberately no non-streaming fallback.
- docs/architecture.md retry section describes the collapsed
  transport: the two stacked retry ladders, IncompleteStreamError /
  ResponsesStreamFailedError retryability, finish_reason_optional
  remediation; stale non-streaming mentions updated (+ puml).
- Anthropic whole-block emission carries its residual hybrid-gateway
  bet as an explicit comment.

Held on standing rulings: post-finish usage forfeiture (keep result +
warn, rounds 4/8), session merge_usage twin and StreamAbortRef twin
(#832), stream_options wire delta (round 2, caveat now names Azure).
2026-07-13 22:39:19 -07:00
Patrick Buckley 49d33d8594 fix(providers): review round 8 — under-streaming gateway parity, abort-race close, usage-blip visibility
Correctness:
- Responses: output that exists ONLY in the terminal payload (buffering
  gateways that never fire output_text.delta / output_item.added) now
  reaches CompletionResult.content and tool_calls — the retired
  non-streaming _parse_response read this same payload, so the drain
  must too instead of returning a clean-looking empty success (blank
  compaction summary, silently-skipped tool call). Gated on nothing of
  that kind having streamed; refusal parts render as the streaming
  branch does.
- Anthropic: content pre-populated inside content_block_start (whole-
  block lax-gateway emission — the real API sends start blocks empty)
  is emitted for text, thinking, and tool_use input, type-guarded like
  _reasoning_text so duck-typed blocks can't leak non-strings.
- Responses: a response.completed payload that OMITS status maps to
  "stop" via the event type, matching the payload-less branch — the
  empty-string status read as 'length' and fired truncation policies
  on complete output.
- model_turn: the drain-retry loop re-checks cancel_ref.aborted after
  the backoff sleep — an abort landing mid-sleep now kills the
  abandoned worker with the original failure instead of issuing one
  more full request behind the deadline's back.
- drain_stream: the post-finish transport-blip tolerance logs a
  warning naming whether usage was captured — the kept result may
  report usage=None (chat-lane usage trails the finish reason) and
  that spend was vanishing from usage accounting with no signal.

Cleanup:
- ChatSession's inline tool-call fold adopts accumulate_tool_call_delta
  (drop-in — same ToolCallDelta semantics), so THE merge rule now has
  one implementation across the chat loop, drain_stream, and the
  Google capture; the helper's mirror-mandate docstring is retired.
- The task-agent _api_call contract comment reconciles the two retry
  layers (sub-harness owns request-level policy; model_turn owns
  drain-time re-issue) instead of claiming model_turn is policy-free.
- Anthropic's three terminal-emission sites share one
  _attach_terminal_blocks helper — replay fidelity can't depend on
  which terminal path a stream took.
- Responses create_streaming resolves capabilities once.

Held on standing rulings: o-series capability-row removal (4th report;
deliberate break, release-noted), StreamAbortRef/_CancelRef unification
(#832; docstring mirror-mandate).
2026-07-13 22:39:19 -07:00
Patrick Buckley 8fa0e7a29e fix(providers): review round 7 — id-disciplined slots, all-lane finish tolerance, retry backoff
Correctness:
- ToolCallSlotter: a slot whose id is KNOWN never splits on an id-less
  delta — on an id-disciplined server new calls arrive with ids, so an
  id-less fragment (the call's FIRST name announcement included) is
  always a continuation. Round-6 regression: {id} → {name} → {args}
  emission split into an unnamed id-bearing call plus a nameless twin.
  Also: a name arriving for a slot with no name yet never splits
  (args-first emission), and a bare same-name delta after complete
  arguments merges as a redundant footer instead of minting a phantom
  zero-argument call that would re-run a side-effecting tool.
- finish_reason_optional is honored on every drained lane, not just
  Chat Completions: Anthropic shims a missing message_delta
  stop_reason + message_stop pair, Responses a missing terminal event
  (both with collected blocks riding the shimmed finish) — the
  documented capabilities-JSON remediation now works on the
  anthropic-compatible/responses-compat gateways it was written for,
  matching the retired non-streaming paths' tolerance.
- Responses: an in-band error/response.failed frame arriving AFTER the
  terminal event is teardown noise — log and end the stream instead of
  raising away a generation already in hand (the in-band twin of
  drain_stream's post-finish transport-blip tolerance).
- model_turn drain retries pace like the SDK request retry they
  replace: 0.5s base, doubling, ±50% jitter — instant re-issues
  re-hit the still-active rate limit/overload and synchronize into
  fleet-scale retry bursts.
- Responses slot bookkeeping survives lax servers: slots minted by a
  counter (len(dict) collided calls after a duplicate/empty item-id
  overwrite), orphan argument deltas route to the most recently
  announced call instead of hardwired slot 0.

Cleanup:
- on_tool_call_delta now receives the normalized ToolCallDelta plus the
  raw SDK delta — Google's capture accumulates the exact bytes the
  mirror sees (the byte-identical extraction no longer exists twice).
- _ArgsScanner feeds only fully id-less slots (its verdict is never
  consulted for id'd slots — dominant-case hot path).
- Anthropic retryable set hoisted to a class constant (per-access
  frozenset allocation, same pattern already fixed on Responses).
- GoogleProvider class docstring names the hook-based capture instead
  of the deleted _extract_tool_calls override.
2026-07-13 22:39:19 -07:00
Patrick Buckley 16647db1b0 fix(providers): review round 6 — strict-by-default finish gate, slotter v3, drain retry
Correctness:
- The chat-lane finish shim is now armed only by an operator-declared
  finish_reason_optional capability (model-definition capabilities JSON).
  Default lanes treat a clean finish-less end as died-mid-generation
  (retryable) — SSE cannot distinguish lax-server completion from a
  worker dying behind a clean-closing proxy, and the default must catch
  truncation rather than bless it. When armed, reasoning-only output
  counts as a completed generation (parity with the retired
  non-streaming path's finish_reason-or-stop default).
- ToolCallSlotter v3: id-less call-boundary decisions now consult
  argument JSON completeness (incremental scanner) and name identity
  instead of a boolean has-args gate. Fixes both residual id-less
  ambiguities: two zero-argument whole-delta parallel calls no longer
  fuse (silently dropping an action), and redundant per-fragment name
  headers no longer split one call into malformed half-JSON calls.
- model_turn re-issues transient mid-stream deaths (provider's
  retryable_error_names, raised while draining) up to twice — the new
  home of the SDK request-level retry the non-streaming transport gave
  every single-shot lane (judge, title, perception, compaction).
  Request-time failures keep the SDK's own policy; an aborted
  cancel_ref suppresses re-issue (StreamAbortRef gains .aborted).

Cleanup:
- One slotter drives both the normalized mirror and Google's raw
  fidelity capture via an on_tool_call_delta hook — raw/mirror slot
  parity is structural now, not a maintained invariant.
- accumulate_tool_call_delta in _protocol.py is THE tool-call merge
  rule; drain_stream and the Google capture use it (session's copy is
  #832's tracked adoption).
- Responses terminal rebuild only runs when the terminal payload can
  disagree with the .done-collected items (truncation or count
  mismatch); on rebuild, annotations are replaced, not re-extended.
- Responses retryable set precomputed at class creation.

Held on standing rulings: o-series capability-row removal (deliberate,
release-noted with remediation), StreamAbortRef/_CancelRef unification
(#832; docstrings mandate mirroring until then).
2026-07-13 22:39:19 -07:00
Patrick Buckley 91c46051d9 feat(providers): drop o-series and pre-5.4 GPT-5 capability rows
The OpenAI commercial capability table floor is now gpt-5.4: o1,
o1-mini, o3, o3-mini, o3-pro, o4-mini, gpt-5, gpt-5-mini, gpt-5-nano,
gpt-5-pro, gpt-5.1, gpt-5.1-codex-max, gpt-5.2, gpt-5.2-pro, and
gpt-5.3 are effectively unused in the field. The gpt-5-search-api row
(different product surface) and the audio/STT/TTS rows stay.

A legacy id now resolves to OPENAI_DEFAULT (temperature sent, no
declared effort vocabulary, 200K window) — which those models may
reject; the remediation is the model definition's capabilities JSON or
a current model, release-noted under Unreleased → Removed.

This also retires the transport-collapse review's thrice-reported
"stream-rejecting o1-era models are stranded" finding by removing its
subject: no row in the table describes a non-streaming model anymore.

Tests migrate to 5.4-era equivalents that pin the same behaviors:
always-reasoning temperature suppression and off-list effort snap
(gpt-5.4-pro for gpt-5-pro/o3), explicit-none forwarding (gpt-5.4 for
gpt-5.1), empty-effort-vocabulary knob drop (gpt-5-search-api for
o1-mini), and the longest-prefix shadow hazard (gpt-5.4-pro vs gpt-5.4
for codex-max vs gpt-5.1).
2026-07-13 22:39:19 -07:00
Patrick Buckley 3a28dc2f16 fix(providers): review round 5 — same-id fragment merge, post-finish blip tolerance, chat finish shim
Correctness:

- ToolCallSlotter's reannounce split is gated to ID-LESS deltas: id
  equality proves the same call, so compat servers that repeat the
  id+name header on every argument fragment merge back into one call
  with valid JSON (round 4's ungated heuristic split them into
  duplicate half-JSON calls — execution-confirmed by the review).  The
  residual id-less repeat-name-per-fragment shape is documented as
  inherently ambiguous; ids are the only disambiguator.
- drain_stream keeps a completed result when the transport blips AFTER
  the finish reason (trailing usage chunk / citation footer window):
  the generation is in hand, so forfeit the trailing metadata instead
  of discarding a fully-delivered verdict or re-paying a compaction.
- The chat iterator shims finish_reason="stop" when a stream ends
  CLEANLY after delivering content or tool calls — the deleted
  non-streaming `or "stop"` default for lax finish-reason-less servers,
  now safe to restore because abrupt deaths surface as
  httpx.TransportError (round 4) rather than clean exhaustion.  This
  supersedes the round-3 keep-the-gate ruling: the httpx catch changed
  the calculus, and the Anthropic/Responses lanes already got their
  marker-based shims.  Empty/reasoning-only streams still fail the
  complete-or-error gate.  Two streaming tests gained the shim chunk.

Dispositions held: o1-era stream-rejecting models (third re-report)
stay a release-note remediation per the earlier ruling.

Cleanup: the two Responses terminal branches collapse into one path
(status derived from the event type when the payload is missing —
also fixes the end-of-stream debug log reporting finish_reason=None
for completed lax streams); the annotations walk is one shared helper
(the two copies had already diverged on None-content guarding);
_raise_responses_failure is annotated NoReturn; scripts/livepass.py
drops the phantom supports_streaming key; test_model_registry's
capture helpers ride scripted_chat_client; _openai_stream_chunk points
at its fake_chat_stream shape-twin for future consolidation.
2026-07-13 22:39:19 -07:00
Patrick Buckley cf7cfe8932 fix(providers): review round 4 — wire-error retryability, tap/mirror slot parity, terminal completeness
Correctness:

- drain_stream chains raw httpx.TransportError from stream iteration
  into retryable IncompleteStreamError (original type+message preserved
  via __cause__): streaming moved the body read out of the SDK's
  APIConnectionError-wrapped request, so mid-body connection drops and
  read timeouts — retried transparently on 1.7 — were escaping every
  single-shot retry loop as instantly-fatal raw httpx names.
- The index remap is extracted as ToolCallSlotter and GoogleProvider's
  raw tap slots THROUGH IT over the same delta sequence as the base
  iterator: round 3's mirror-side de-fusion had left the tap keying by
  wire index, so a degenerate stream produced 2 mirror calls vs 1 fused
  raw dict — _prepare_messages' length gate then silently dropped the
  thought_signature lane (400 on signature-strict Gemini models).
- The slotter also splits ID-LESS degenerate parallel calls: a delta
  announcing a name for a slot that already accumulated arguments is a
  second whole call, not a fragment (fragmented single calls pinned
  unaffected).
- A payload-less Responses terminal event keeps the provider_blocks
  already collected from output_item.done events (they came from the
  stream, not the missing payload); only usage is genuinely lost.
- The truncation-rebuild path walks the terminal output's message
  annotations, so truncated web-search turns keep their Sources footer
  (the in-flight item never received output_item.done).

Cleanup: one _raise_responses_failure ladder serves both in-band
failure shapes (error events + response.failed); IncompleteStreamError
joins the public providers export (docstrings tell callers to catch
it); the de-fusion tests ride the file's existing _openai_stream_chunk
helpers instead of a third hand-rolled SSE fake; the dead if-response
guard in the terminal branch is gone.

Deferred with note: classifying IncompleteStreamError once at the
retry-predicate consultation site instead of per-provider strings is
#832 territory (the predicate lives in ChatSession); the six-lane
parametrized test guards the listing until then.
2026-07-13 22:39:19 -07:00
Patrick Buckley 56b7674dfa fix(providers): review round 3 — in-band error events, terminal-marker tolerance, adapter-owned de-fusion
Correctness:

- Responses _iter_stream handles the SDK's in-band `error` SSE event
  (ResponseErrorEvent is YIELDED, not raised, and no response.failed
  need follow): the real API code/message now surfaces — code-gated for
  retryability like response.failed — instead of the stream exhausting
  finish-less and hiding the cause behind a retried
  IncompleteStreamError.
- Anthropic message_stop supplies a missing stop_reason: it is a genuine
  terminal marker, so a compat /v1/messages shim whose message_delta
  omits stop_reason completes (blocks intact) rather than failing a
  generation that arrived — tolerance the retired non-streaming default
  provided, restored without weakening the died-mid-response gate.
- A Responses terminal event without its response payload still emits
  the finish reason its type implies (lax compat servers), losing only
  usage/blocks rather than the whole result.

Dispositions held (documented, not re-coded): the complete-or-error
gate stays for finish-less Chat Completions streams — indistinguishable
in-band from a died generation, and silent partial-storage is the worse
failure; CHANGELOG now names the shape and each provider's accepted
terminal markers. supports_streaming deletion and the stream_options
wire delta were ruled earlier and keep their release-note remediations.

Cleanup: index-degenerate de-fusion MOVED from drain_stream into the
chat adapter's iterator (mirroring the Anthropic iterator's index
assignment) so the interactive loop is fixed too and the drain returns
to a plain mirror of the main-loop accumulator; a parametrized test
locks "IncompleteStreamError is retryable" across all six provider
lanes instead of trusting per-adapter memory; scripted_anthropic_client
joins scripted_chat_client (shared _ScriptedClient class, no function
attrs) and the two remaining hand-rolled anthropic closures convert.
2026-07-13 22:39:19 -07:00
Patrick Buckley 3ffa8b9057 fix(providers): review round 2 — complete-or-error drain, code-gated retries, truncation-safe blocks
Correctness (3 confirmed + 2 plausible, all fixed):

- drain_stream now raises typed, retryable IncompleteStreamError when a
  stream exhausts without any finish reason — every adapter emits one on
  a healthy stream, so its absence means the generation died
  mid-response behind a cleanly-closing proxy.  This restores the
  retired transport's complete-or-error contract (a half-generated
  compaction summary was previously returned as finish=stop and stored,
  silently replacing real history) and DELETES round 1's suffix-info
  fold: with no finish-less success path there is nothing to classify,
  so a trailing status ping can never be stored as content either.
- Index-degenerate parallel tool calls get distinct slots: a delta whose
  id differs from its slot's opens a new call (id-less fragments still
  follow their index's current call), so historical compat servers that
  emit every parallel call at index 0 no longer fuse distinct calls
  into concatenated garbage arguments.  Result order stays index-sorted
  (stable) like the retired array parse.
- response.failed retryability is code-gated: only transient codes
  (server_error, rate_limit_exceeded) raise the retryable typed error;
  deterministic rejections (invalid prompt, image fetch, policy) raise
  plain RuntimeError and stop retry loops on attempt zero instead of
  running the full backoff ladder against a doomed request.
- Terminal Responses events rebuild provider_blocks from
  response.output when present: the item being generated at
  max_output_tokens truncation never receives output_item.done, and
  storing a reasoning item without its required following item made the
  next turn's replay a 400.
- merge_usage's base case uses dataclasses.replace so a future UsageInfo
  field can't be silently zeroed on drained lanes.

Cleanup: run_abortable_with_deadline bundles the three-point abort
wiring (ref + cancel_ref + on_abandon) so it cannot be half-wired —
both judges converted; scripted_chat_client hoists the 14 chat-lane
fake_create closures (call scripts + .calls recording replace per-test
counter cells); fake_chat_stream gains reasoning=, collapsing the
reasoning-capture suite's hand-rolled chunk shape; FakeAnthropicBlock
hoists the duplicated _Block test class; the class and judge PlantUML
diagrams drop the retired create_completion flow.

Also converts test_model_registry's agent-model fakes, which returned
legacy response objects that iterated as EMPTY streams — they only
passed through the old drain's silent finish=stop default, exactly the
hazard the new gate exists to catch.
2026-07-13 22:39:19 -07:00
Patrick Buckley 08580f25f9 fix(providers): review round 1 — streaming parity gaps the collapse exposed
Correctness (4 confirmed + 1 plausible fixed, 2 accepted+documented):

- Anthropic _iter_anthropic_stream handles citations_delta: text-block
  citations now ride the raw block into provider_blocks, as replay
  requires (the retired non-streaming lane preserved them via
  model_dump; the streaming lane dropped them — a pre-existing main-loop
  gap the collapse would have extended to single-shot lanes).
- Anthropic text blocks separate with "\n" at each subsequent block
  start, restoring the retired lane's "\n".join rendering on drained
  lanes AND un-fusing streamed web-search responses in the chat loop.
- response.failed raises typed ResponsesStreamFailedError, listed in the
  provider's retryable_error_names — retry loops treat an in-band
  failure like the wire errors it stands in for instead of
  hard-stopping on a bare RuntimeError (judges keep their heuristic
  fallback after retries).
- drain_stream folds a finish-less stream's terminal citations footer
  (suffix rule: pre-finish info invalidated by any later payload), so
  lax compat servers that never send finish_reason keep their Sources.
- usage max-merge extracted as merge_usage() in _protocol.py — the one
  definition drain uses now and the session's inline consumer adopts on
  #832.

Accepted + release-noted instead of coded around: strict pre-2024
compat servers that 400 on stream_options (such a server already cannot
serve the chat loop; CHANGELOG caveat extended), and repeated-index
parallel tool-call merging on legacy compat servers (identical to the
main loop's accumulator semantics; a shared guard belongs in the #832
unification).

Cleanup: run_with_deadline grows on_abandon (best-effort, cannot mask
the deadline error) and both judges drop the copy-pasted abort
choreography; StreamAbortRef documents the _CancelRef adoption plan;
test_model_turn's fake replays through the shared as_stream adapter;
docs/architecture.md drops the retired Protocol row.

Tests: refusal handler pinned (was advertised, untested); typed-failed
retryability; citations capture; text-block separator (plus the mixed
text+search expectation updated for the separator chunk); finish-less
citation fold; on_abandon firing matrix; StreamAbortRef arrival race.
2026-07-13 22:39:19 -07:00
Patrick Buckley 1e7ad7bcb6 feat(providers): one transport — drain create_streaming, retire create_completion (#831)
Every single-shot lane (model_turn: judges, titles, compaction, web-fetch
extraction, perception, eval, optimizer) now samples through the provider's
streaming entry and accumulates via a shared drain_stream(), deleting
create_completion from the Protocol and all three adapters (xai/google
inherit). Request shaping can no longer drift between the two consumption
styles, and callers keep the exact CompletionResult contract.

The drain mirrors the main loop's proven chunk semantics: per-field
max-merge for usage (Anthropic splits prompt/completion across
message_start/message_delta), tool-call assembly by delta index,
provider_blocks from the terminal emission, trailing citation info folded
back into content (byte-matching the old format_citations append),
mid-stream status pings dropped.

Also in this change:

- model_turn grows cancel_ref; both judges wire their run_with_deadline
  abandon paths to a new StreamAbortRef (deadline.py) that closes the SDK
  stream — a timed-out judge call now aborts its HTTP read instead of
  pinning a daemon thread until the next upstream chunk. The append hook
  covers the arrival race, mirroring ChatSession._CancelRef.
- Responses streaming gains the response.incomplete terminal handler
  (truncated runs were mislabeled finish=stop and lost final usage AND
  collected provider_blocks) and a refusal handler ([Refused: …] content,
  matching the retired non-streaming rendering). Both also fix the main
  chat loop, which shared the gaps.
- supports_streaming capability flag deleted (zero readers) along with
  its admin capability tile; o1-era models that reject streaming need a
  model alias pointing at a current model (release-noted).
- Helpers that existed only for the deleted transport go with it:
  Responses._parse_response, chat/google._extract_tool_calls.

Known behavioral deltas (release-noted): OpenAI-compatible servers that
ignore stream_options.include_usage stop producing usage rows on these
lanes; multiple Anthropic text blocks concatenate without the old "\n"
joint (matching the main loop); model_turn lanes no longer risk client
read-timeouts on long generations — the reason the Anthropic adapter
already drained a stream internally.

Tests: new test_drain_stream.py pins the accumulator rules; shared fakes
(as_stream, fake_chat_stream, fake_anthropic_stream) migrate 11 suites to
the streaming transport, with the task-agent and adapter suites now
exercising the real _iter_stream + drain path end to end.
2026-07-13 22:39:19 -07:00
Patrick Buckley a66e9d456d fix(mcp): gate the marker release on non-acquisition; structure the paired protocols
Close the round-8 review findings:

- The refresh runner's finally-discard releases the coalesce marker
  ONLY when the lock was never acquired (cancelled while parked).
  After the at-acquire discard, a marker present at exit belongs to
  the successor spawned during the in-flight list call — discarding
  it unconditionally let the handler mint one extra runner per
  debounce window while the lock was congested, reopening the
  unbounded runner FIFO the marker exists to bound.

- The observe-before-lookup preamble lives once in
  _pool_lookup_checked (snapshot taken synchronously before the
  lookup await, render paired with the convergence drop) instead of
  verbatim in all three dispatchers — the ordering contract is now
  structural rather than comment discipline.

- drop_session is paired with its debounce-stamp pop in
  _drop_session_and_stamp, shared by the eviction, teardown, and
  owner-death paths; the shutdown sweep clears the pool notification
  stamp dict and the coalesce marker set alongside the other pool
  state.

- _mcp_tools_change_seq is initialized unconditionally for every
  session kind, so the attribute's existence no longer encodes
  whether an MCP client was wired at construction.
2026-07-13 21:13:42 -07:00
Patrick Buckley e9ecf91c07 fix(mcp): observe before the lookup; coalesce queued refreshes; pop stamps on every teardown
Close the round-7 review findings:

- The dead-grant observation is now snapshotted BEFORE the classified
  lookup's first await, by the callers (the three dispatchers via
  _pool_lookup_failure, _prime_one, and the obo credential gate), and
  _schedule_dead_grant_drop requires it as a parameter: snapshotting
  after the lookup returned could capture a session the
  consent-completion prime connected mid-lookup — its awaits can park
  on executor hops — and the drop then evicted the just-restored
  catalog it exists to spare, with no remaining re-prime path.

- Spawned list_changed refreshes coalesce on a per-(key, kind) marker:
  set at spawn, cleared the moment the runner acquires open_lock
  (before its list call, so a change the in-flight list missed spawns
  exactly one successor). Admission was one per 5s debounce window
  while each runner can hold the lock up to the 30s refresh timeout,
  so a notifying-but-slow server accreted lock waiters without bound —
  FIFO dispatch waits past the 120s budget, idle eviction starved by
  the contested lock, and background tasks growing for as long as the
  server kept notifying. The runner also returns quietly for an
  evicted session instead of failing through the log. The residual
  duty-cycle case (a wedged-but-notifying server defers idle eviction
  of its own entry until the first dispatch, recovery, or silence) is
  documented at the runner.

- Every teardown path now pops the notification debounce stamp:
  _teardown_pool_entry and _on_pool_owner_death left it in place, so
  the keep-stamp design's documented reconnect backstop did not exist
  on the idle-collapse and connect-failure paths — a change announced
  in a failed window could be debounced against a pre-collapse stamp
  after reconnect and never land. The idle-close path's own pop is
  now owned by _teardown_pool_entry.

- Cleanups: the notification table maps type to kind label only, with
  the kind-to-refresher map bound at dispatch time (mypy-checked
  attribute references, instance overrides keep working) instead of
  getattr on a name string; _schedule_dead_grant_drop skips when there
  is provably nothing to converge (no entry, or a session-less
  catalog-less stub), sparing a tracked no-op task per unconsented
  server per prime at scale; the fire-and-forget prime idiom's three
  hand-synced copies collapse into try_prime_user_pools (session
  construction, acting-user change, OIDC capture); the stale
  lock-contract docstrings on the resources/prompts refreshers now
  state the held-lock requirement; has_live_session_listener is the
  sole listener-liveness predicate (the private alias is gone); the
  construction-scoped tools-seq read is a constructor local instead of
  a persistent ChatSession attribute.
2026-07-13 21:13:42 -07:00
Patrick Buckley 7186e1e709 fix(mcp): observe sessions at dead-grant discovery; serialize spawned refreshes
Close the round-6 review findings, all in the round-5 surface:

- Dead-grant drops snapshot the entry's session when the failed lookup
  is observed and skip only when the session CHANGED since: a warm
  transport that predates the revocation is evicted with the catalog
  (failed lookups short-circuit dispatch before any 401 could evict it,
  so nothing else converges a warm entry until the idle TTL), while a
  session a re-consent prime created after the observation still parks
  the drop. The obo credential gate inherits the same semantics for
  warm obo entries.

- The spawned notification refresh serializes on open_lock with a
  same-entry recheck: unserialized it raced the connect wiring block
  (older discovery snapshot republished over the refresh's newer
  catalog, permanently hiding the change behind the consumed debounce
  stamp) and sibling same-key refreshes (the slower list call
  publishing the older catalog last).

- The refresh failure path keeps the debounce stamp instead of popping
  it: pop-on-failure re-armed the handler on every notification, so a
  fast-failing server spawned refresh tasks unthrottled at its
  notification rate. Changes announced in a failed window converge on
  the next list_changed or reconnect (teardown pops the stamp).

- The refresh runner catches BaseExceptionGroup alongside Exception: a
  wedged anyio transport surfaces session-op failures as groups, which
  escaped to the background-task failure log whose exc_info serializes
  the chained httpx request carrying the user's bearer.

- Cleanups: the three list_changed handler branches collapse into one
  table-driven path; _reprime_active_users reuses _live_listener_uids;
  the obo gate's synthesized kind="missing" verdict is contract-pinned
  to get_obo_access_token_classified's missing-credential return.
2026-07-13 21:13:42 -07:00
Patrick Buckley 2ce6638761 fix(mcp): spawn list_changed refreshes off the receive loop; close round-5 findings
The headline finding is pre-existing and structural, surfaced by this
branch's timeout: the SDK awaits notification handlers INLINE in its
receive loop, so a handler that awaits a request on the same session
can never receive its response — push-driven catalog refreshes have
never completed against a healthy server, and with the new timeout
they also stalled every in-flight call on the session for its
duration. Refreshes are now spawned as tracked background tasks, and
a FAILED refresh returns the debounce stamp so the server's next
list_changed retries instead of being dropped inside the window.

Also from the round:
- The obo credential-presence gate skipped exactly the per-server
  lookup whose kind='missing' would have dropped retained catalogs, so
  unlinked users' ghosts survived every new-session prime. The gate
  now schedules the same dead-grant drop for catalog-bearing obo
  entries before skipping the servers.
- Dead-grant drops re-validate under open_lock via skip_if_connected:
  a drop parked behind a re-consent prime's connect must not clear the
  freshly restored catalog (a live session proves a connect succeeded
  after the failed lookup that scheduled the drop). The explicit
  revocation path still clears warm entries unconditionally.
- The constructor's convergence re-check moved to the end of tool
  setup, where every _on_mcp_tools_changed dependency exists — the
  while-loop re-read could still be clobbered by the tool-search
  construction reading mixed state, and a mid-construction callback
  crash (pre-existing, swallowed by the fan-out) loses its update.
- _drop_catalog_locked's docstring told the truth about its wait bound
  (a same-key dispatch holds open_lock across its entire SDK call).
- The OIDC capture-site liveness gate call moved inside its try —
  nothing on that best-effort path may fail a login.
- One _pool_lookup_failure helper pairs render+drop for all three
  dispatchers; fake pool-tool seeds deduped to one module helper; the
  sleep-based test syncs replaced with a deterministic
  _background_tasks drain.
2026-07-13 21:13:42 -07:00
Patrick Buckley 1c4761971c fix(mcp): strip the revocation-generation protocol; keep the stable core
Round 4 confirmed six correctness bugs, all inside round 3's
catalog_gen machinery (a ChatSession.__init__ crash from the mirror-
race re-run, an orphaned-lock race created by the ensure-before-lookup
reorder, no generation memory across entry re-creation, gen reset on
re-ensure, a raw internal error surfacing to the session layer). Four
rounds of evidence: hardening this event-driven subsystem with new
concurrency machinery breeds interaction bugs about as fast as it
closes cosmetic races. Decision: remove the protocol, keep the core.

Stripped: PoolEntryState.catalog_gen, the expected_gen threading
through dispatch/prime/connect, the dispatcher ensure-before-lookup
reorders, _PoolGrantRevokedError, and the refresh gen-guards (the
entry-identity check stays — it protects against entry replacement
with no protocol). The publisher-suspended-across-a-drop races those
closed are now ACCEPTED RESIDUALS, documented at
_evict_session_drop_catalog: the ghost self-heals at next use via the
dead-grant drop (dispatch AND priming), and a reconnected stale bearer
dies at access-token expiry — the same bound every warm session
already rides at revocation time.

Kept from round 3 (stable, orthogonal): staged discovery publication,
prime-side dead-grant drops, the obo re-login prime, the single
_pool_lookup_verdict classification, drop_session() pairing, and the
tracked revocation drop task.

Fixed from round 4's orthogonal findings:
- ChatSession construction converges its tool lists with a bounded
  re-read loop instead of calling _on_mcp_tools_changed, which
  dereferences tool-search state initialized later in construction.
- _refresh_pool_server_tools gets the asyncio.timeout its resource and
  prompt siblings already had — a wedged server no longer hangs the
  notification-handler task.
- The OIDC capture-site prime is gated on the user having a live
  session listener (new public has_live_session_listener): routine SSO
  re-logins with nothing open no longer fan out mints and connects.
- _pool_lookup_verdict returns a Literal so a typo'd verdict
  comparison fails mypy instead of silently never matching.
- The triplicated double-401 comment blocks shrink to two-liners
  pointing at the single rationale in _evict_session's docstring.
2026-07-13 21:13:42 -07:00
Patrick Buckley 1e84f62619 fix(mcp): round-3 review fixes — revocation generation for catalog publishers
Round 3 identified the class behind the remaining bugs: catalog
PUBLISHERS never re-validate revocation state, so anything that read a
token or suspended before a drop could republish (resurrect) a revoked
catalog that retention then keeps forever. One primitive closes the
class:

- PoolEntryState.catalog_gen, bumped by _evict_session_drop_catalog.
  The three list_changed refreshes snapshot it before their awaits and
  discard results if it moved; dispatch and priming snapshot it before
  their token reads, and _connect_one_pool refuses to connect (raising
  _PoolGrantRevokedError, a non-breaker failure) when the generation
  moved past the caller's snapshot — the bearer in hand predates a
  disconnect.
- _connect_one_pool stages all three discovery results locally and
  publishes them together in the final wiring block: a mid-discovery
  failure now leaves the retained catalog exactly as it was instead of
  a torn half-update diverging from the per-user maps.
- Priming converges dead grants too: _prime_one schedules the same
  catalog drop the dispatchers use, so a NEW session's prime clears
  ghosts left by a disconnect made on another node.
- obo re-login is the obo restore moment: a successful credential
  capture at the OIDC callback now schedules prime_user_pools, so a
  previously dropped obo catalog returns to LIVE sessions (obo has no
  consent flow to heal through).
- ChatSession construction re-runs its tool rebuild when the change
  marker advanced during its authoritative read — the mirror race
  where a fresher listener update was clobbered by the constructor's
  staler snapshot.
- evict_user_session's drop task is now tracked (_spawn_background) so
  shutdown cancels it instead of abandoning a parked task.

Dedup/altitude from the round: _schedule_dead_grant_drop is the single
drop block (was three byte-identical copies); _pool_lookup_verdict is
the single lookup classification — rendering and _lookup_grant_dead
both derive from it, with literal code strings kept so the consent-url
sibling audit still sees the sites (expected count 7 -> 5 after the
collapse); PoolEntryState.drop_session() pairs session/bound_token
clearing structurally (owner-death was missing the bearer clear).
2026-07-13 21:13:42 -07:00
Patrick Buckley 49a30c1547 fix(mcp): round-2 review fixes for the catalog-retention branch
Five confirmed correctness findings, all in the round-1 fix code:

- The dead-grant catalog drop at the token-lookup error sites is now
  SCHEDULED instead of awaited: the drop waits on open_lock, which a
  same-key dispatch holds across its entire SDK call, so awaiting let
  a token-side error stall past the sync timeout and charge the
  breaker it is documented to bypass.
- The double-401 drop is removed entirely: a second 401 after a
  SUCCESSFUL forced refresh proves the grant is alive at the AS — it
  is the resource server rejecting a fresh bearer (JWKS lag, audience
  misconfig, clock skew), and dropping the catalog made RS recovery
  unhealable for live sessions. A genuinely revoked grant converges
  via the token-lookup drop (its row is gone by then).
- The drop decision has one source of truth (_lookup_grant_dead),
  gated on the token store + storage actually being wired: the obo
  lookup returns kind='missing' for boot-window infrastructure
  absences too, which must not clear catalogs. The empty-token
  fallback now classifies with its consent_required siblings.
- The LRU pass re-checks the LIVE warm count per iteration again —
  the one-shot over-count never saw concurrent warm-set changes
  (revocation evictions, owner deaths, connects) and closed healthy
  transports below the cap.
- _on_pool_owner_death clears bound_token: the third session-drop
  site the bearer-clearing sweep missed, and the one that cools an
  entry indefinitely.

Also from the round: reconcile stores both pool-name registries as
adjacent assignments and _retain_cooled documents the residual
single-bytecode flip-tear window (restored by the same reconcile's
re-prime); catalog-less drops skip the zero-delta rebuild+notify
fan-out; session construction does one authoritative post-registration
read instead of read-twice; evict_user_session schedules the locked
drop directly.
2026-07-13 21:13:42 -07:00
Patrick Buckley 4d89efa7b8 fix(mcp): registry-liveness for cooled entries, dead-grant convergence, revoke interlock
Fix round for the review of the #836 catalog-retention change
(14 findings: 9 correctness, 5 cleanup):

- Cooled retention now requires the server to still exist in the pool
  registries (_retain_cooled — ONE policy shared by the TTL skip and
  the close path): an admin delete/disable/rename/auth-flip drops the
  ghost catalog within one eviction tick. Pre-#836 the idle TTL
  bounded such ghosts to ~10 minutes; retention made them immortal,
  including a disabled server that stayed dispatchable and duplicate
  tool names after a flip to static.
- A dispatch that learns the grant is durably GONE (token row missing
  or refresh permanently rejected — the mcp_consent_required class)
  drops that (user, server) catalog, so a disconnect made on another
  node converges here at first touch instead of re-offering revoked
  tools behind a consent card. Re-consent restores the tools through
  the existing consent-completion single-server prime.
- Revocation drops serialize against an in-flight connect via the
  entry's open_lock (_drop_catalog_locked): an unserialized drop was
  republished (resurrected) by the connect's completing discovery,
  with nothing left to ever clear it.
- The LRU pass counts closes incrementally and the TTL pass checks a
  once-per-tick listener snapshot instead of scanning the listener
  registry per entry under its lock.
- bound_token (a plaintext bearer) is cleared whenever the session is
  dropped — it is dead on a session-less entry, and cooling otherwise
  retained it for the life of the user's sessions.
- Per-user status falls back to the cooled catalog for its counts and
  reports the idle pool separately (user_pools_idle): cooled is the
  steady state now, and the warm-only view said '0 tools' for a
  catalog the same user's chat was actively offered.
- Session construction re-reads the merged tool lists after listener
  registration, closing the read-then-register window that missed a
  concurrent drop's only notification.
- Dedup: one retention policy, one warm predicate, one rebuild+notify
  sequence (was three copies), and the drop-catalog path now layers
  on _evict_session instead of copying its prologue.

Known limits, deliberately deferred: shared-workstream participants
who are not the acting user still lose their catalogs at TTL (not a
regression — the next send re-primes), and the pre-existing
orphaned-lock race on full-drop is unchanged.
2026-07-13 21:13:42 -07:00
Patrick Buckley cb94ea349f fix(mcp): retain per-user catalogs when pool sessions close under live sessions
Idle-TTL eviction tore down a per-user pool entry, rebuilt the user's
tool/resource/prompt catalogs (now empty), and notified listeners — so
every live ChatSession for that user silently lost the server's tools
after 10 idle minutes, with no way back: prime_user_pools only runs at
session construction, acting-user change, and reconcile, and the
emptied catalog closes the session-side is_mcp_tool gate, so even a
history-motivated call can't reach the lazy-reconnect dispatch path.
The dispatch-failure paths (_evict_session on 401/403/transport)
cleared catalogs the same way, so a transport blip during a tool call
caused the same permanent loss with no TTL involved — and made the
breaker's half-open recovery and the consent/step-up cards unreachable.

Both now follow _on_pool_owner_death's evict-session-keep-entry shape:

- _evict_session drops only the session. The catalog stays; the next
  dispatch connect-or-reuses and re-runs discovery, so drift
  self-corrects and the refresh notification fans out then.
- TTL eviction COOLS entries of users with a live session (a
  registered user-scoped tool listener): transport closed, entry and
  catalog retained, no fan-out. Users without one keep the full drop,
  so departed users' entries don't outlive their sessions.
- The LRU cap now bounds WARM entries — the connection resources it
  exists to limit. Over the cap, live-listener users' entries are
  cooled rather than dropped; cooled catalog-only entries are bounded
  by live users x pool servers and reaped one tick after the user's
  last listener goes away.
- Explicit disconnect keeps its semantics: evict_user_session routes
  to the new _evict_session_drop_catalog (clear + rebuild + notify) —
  the user asked for the tools to leave. Clearing the catalog also
  marks the entry droppable, so it can't linger cooled.

Never-discovered stubs (no catalog) are always dropped, already-cooled
entries are skipped by later ticks, and a cooled entry keeps its
open_lock object for in-flight dispatchers.

Applies to oauth_user and oauth_obo alike: the pool and its eviction
are auth-type-agnostic, and for obo priming is the only path tools
enter a catalog at all.

Fixes #836
2026-07-13 21:13:42 -07:00
Patrick Buckley 3742e9660a docs(changelog): #827 turn-interface unification + sampling-knob assignment scheme with upgrade notes 2026-07-13 08:48:27 -07:00
Patrick Buckley e8a17921bf fix(model-turn): round-3 review — complete the scheme rollout to the main loop, coordinator role, admin save path, and CLI switch
- The main streaming loop now applies the in-code model-definition rung
  (caps.default_reasoning_effort) exactly like model_turn does, so the
  same alias samples identically between chat and every auxiliary lane
  (resolve_lane's stated contract). This also unblocks operator
  temperature on gpt-5.x aliases whose declared default is "none" — the
  main loop previously sent neither knob while aux lanes sent both.
- coordinator.reasoning_effort default "medium" -> "" (the missed unset
  sentinel): coordinators inherit like every other lane; the role rung
  fires only when the operator stored a value.
- admin webux: _onSettingChange no longer hides the save button for a
  blanked nullable number input, so the blank-means-inherit save path is
  actually reachable from the field it decorates.
- /model switch on STORE-LESS sessions (the CLI) keeps the user's
  explicit --temperature//reason knobs when the target alias declares no
  override — the current knobs are the only authority there (mirrors
  the max_tokens fallback). Store-backed sessions still re-resolve.
- ModelLane docstring no longer documents the removed caller-default
  effort rung; CLI status line shows any resolved effort ("medium" is no
  longer a hidden code default); dead `u = usage` alias dropped; three
  test docstrings re-pointed from the deleted
  ChatSession._maybe_synth_reasoning_block to
  model_turn.synth_reasoning_block.
2026-07-13 08:48:27 -07:00
Patrick Buckley 257a8c12ec fix(model-turn): no caller-default effort rung — local vocabularies make any code effort token unsafe
Follow-up ruling on the round-2 batch: default_reasoning_effort is
removed entirely. On local lanes effort_passthrough forwards the value
VERBATIM with the template as the sole authority on validity, and we
explicitly do not define effort vocabularies (or floors) for local
models — so a code-chosen "low" is an unvetted token, and on
manual-thinking boxes it flips enable_thinking on for lanes the
operator never configured, diverging from the main loop's unset. The
effort scheme is now exactly the temperature scheme: explicit relay >
alias > stored config > model definition > omit.

Utility/guard consequences handled the honest way instead:
- title gen: _TITLE_MAX_TOKENS 2048 -> 8192 (the budget must fit a full
  thinking pass at the MODEL'S OWN default now that code never bounds
  it) and the prompt enforces a hard 3-word maximum so the visible
  answer is trivially cheap regardless of what thinking spent.
- output guard: keeps its 512 cap; an unbounded thinking model that
  overruns it parses to a labelled llm_error verdict (heuristic tier
  stands) and the documented remediation is an effort value on the
  guard's model alias.
2026-07-13 08:48:27 -07:00
Patrick Buckley b6391d1f90 fix(model-turn): one sampling-knob assignment scheme — alias > config > model definition > omit
Round-2 review fixes. The round-1 de-pinning collided with
ConfigStore.get's default-on-miss semantics: the registry defaults
(temperature 1.0, effort "medium") were manufactured onto every
store-backed lane's wire, making the documented "unset -> omit"
terminal unreachable. Unset is now representable end to end, and one
scheme governs every lane: per-model alias value > operator-stored
global setting > in-code model definition (effort only: caps
declaration) > field omitted, inference engine's default rules.

- settings_registry: model.temperature default None, model.reasoning_effort
  default "" — the registered defaults ARE the unset sentinels, so the
  admin UI and the wire agree. Admin webux renders nullable floats blank
  ("(inherit model default)") and maps blank-save to reset; the "" effort
  choice reads "(inherit)".
- model_turn: resolve_temperature_setting/resolve_effort_setting are the
  ONE pair of operator-rung resolvers, shared by resolve_lane, both
  session factories, and the /model switch (the 4th-copy mirror is gone;
  the switch no longer leaks the previous model's override on store-less
  sessions). The caps rung moved out of the lane into model_turn's
  effective computation, below a new request-shaped default_reasoning_effort
  parameter (utility + output guard pass "low": budget coherence with
  their small token caps, not sampling policy — any operator or
  model-definition value beats it). The hidden "medium" terminal is gone.
- providers: Protocol + all adapters take reasoning_effort: str | None =
  None (the Protocol-signature "medium" was the same manufactured pin one
  layer down); ModelCapabilities.default_reasoning_effort defaults "" —
  commercial rows all declare theirs explicitly, so only local lanes and
  Anthropic change, both to match their real serving defaults (Anthropic
  manual-thinking models no longer get implicit thinking-on-medium).
  reasoning_template_kwargs distinguishes unset (inject nothing; template
  default rules) from the explicit "none" off-switch. apply_temperature
  skips temperature unless reasoning is EXPLICITLY off on none-declaring
  models (unset leaves the server default in charge, possibly reasoning-on).
- session: ctor takes temperature: float | None / reasoning_effort:
  str | None = None; _save_config/resume round-trip unset as "" (the
  str(None) era guarded); _run_agent relays session temperature AND
  effort on the same-alias fall-through only (a task alias's configured
  knobs stay reachable in both directions).
- optimizer: the five meta lanes are decoupled from --temperature/
  --reasoning-effort (test-model knobs, per their documented meaning);
  registry-less meta lanes omit both fields.
- cli: --temperature/--reasoning-effort default unset and fall through
  the model config instead of pinning 0.5/"medium" for every CLI session.
- cleanup from the review's below-cap findings: dead resolve_server_type
  deleted (tests re-pointed at _server_type_of), stale ChatSession
  comments in _openai_responses fixed, _store_get_or_none extracted,
  eval system-turn conversion hoisted out of the per-turn loop, dead
  _provider_extra_params patch removed, test_perception uses the shared
  mock_completion_result, effort_ladder uses apply_capability_overrides
  instead of a SimpleNamespace fake config.

Wire goldens regenerated: the only drift is the manufactured "medium"
effort vanishing from unset-effort requests (Responses reasoning.effort,
Chat/Google reasoning_effort, Anthropic output_config.effort) — pure
removals, no additions. Ladder tests now fake ConfigStore with the REAL
get() semantics (registry default on miss) so a forgiving fake can't
mask this class of bug again.
2026-07-13 08:48:27 -07:00
Patrick Buckley 09fd17f2da fix(model-turn): reasoning effort rides the ladder too; round-1 review fixes
Patrick's rulings applied from the round-1 high review:

- reasoning_effort loses every code pin, same as temperature: ModelLane
  resolves the ladder (ModelConfig.reasoning_effort → global
  model.reasoning_effort setting → the lane capabilities'
  default_reasoning_effort), model_turn takes str | None, and the pins
  in both judges, all five optimizer lanes, _utility_completion's
  signature default, and model_turn's own "medium" default are gone.
  Explicit relays of user/operator knobs (session effort on the agent
  seam and web-fetch, harness knobs in eval) stay relays.  Effort's
  terminal is the caps default, not wire omission — it gates thinking
  modes, so unset ≠ the explicit "none" value.
- model.temperature setting default 0.5 → 1.0 (safer for modern models;
  several providers no longer accept temperature at all — those drop it
  via capabilities regardless).  No judge-specific knob: a judge alias
  with a per-model override is the remediation path.
- The agent seam keeps the alias ladder (configured → inherited global
  → none), per ruling; the ModelLane docstring no longer documents the
  removed session-relay convention.
- Optimizer lanes get real operator knobs: the existing --temperature /
  --reasoning-effort CLI flags now relay into all five internal LLM
  steps (previously they reached only the eval sessions, leaving the
  deleted pins with no replacement mechanism).

Round-1 cleanups: create_streaming widened to float | None (the
Protocol's two entry points agree; all callers pass explicitly);
model_turn's provider invocation is a direct keyword call again (strict
mypy re-checks it); perception threads the caller's already-resolved
capabilities (one config generation across gate and wire); redundant
extra_params pre-resolution dropped at utility/agent/eval; the synth
source-tag joins the one-fetch-per-call cfg chain; effort_ladder
delegates its capability merge to resolve_capabilities; stale
_maybe_synth_reasoning_block pointers fixed in the providers package.

Wire goldens regenerated: the only drift is the hidden
"temperature": 0.5 pin vanishing from unset-temperature requests.
2026-07-13 08:48:27 -07:00
Patrick Buckley 3eb789dfff fix(model-turn): temperature truly inherits — None never reaches the wire
The second xhigh review caught the fix-round design error one layer
down: omitting the temperature kwarg did not yield the server default —
every adapter's create_completion signature defaulted it to 0.5 and
apply_temperature wrote it to the wire, so the deleted lane pins had
silently become a hidden universal 0.5 pin.

The house rule is now implemented end to end:

- Protocol + adapters take temperature: float | None = None, and None
  is OMITTED from the wire (apply_temperature None-gate; Anthropic's
  builder keeps its API-required thinking=1.0 forcing but never writes
  an unresolved value; Responses/xAI builders widened).
- resolve_lane climbs the documented ladder: ModelConfig.temperature →
  ConfigStore global model.temperature (new config_store param,
  threaded from ChatSession into both judges and perception) → None.
- perception.describe/describe_cached take alias/registry/config_store
  so operator settings on the perception alias actually reach the wire
  (previously structurally unreachable — no remediation path for a
  degraded memoized description).
- The agent seam stops relaying the SESSION model's temperature: the
  task/agent alias's own ladder governs, per the inherit-from-the-model
  contract.

Generation-coherence and audit fixes from the same review:

- ChatSession._resolve_capabilities fetches its config UNCAUGHT again —
  a registry failure on the session's own alias raises loudly instead
  of silently caching degraded static-table caps for the session
  lifetime (the never-crash fetch is a judge-constructor property).
- Judge constructors pass cfg=model_cfg (zero independent get_config
  fetches; pinned by test); the per-evaluation lane's constructor-
  frozen capabilities are documented as deliberate (window-coupled,
  refreshed on judge swap).
- OutputGuardJudge splits _lane_alias from _judge_model_alias so the
  audit label keeps its pre-#827 fallback semantics ("" → raw model id)
  while lane resolution inherits the session alias.
- model_turn fetches the alias config ONCE per call and threads it into
  both live flags (cfg sentinel standardized across the resolvers:
  ... = fetch for me, None = fetched-and-missed — also removes
  resolve_lane's latent double-fetch on a miss).
- cap_tool_calls shared by the eval and optimizer loops; hand-built
  ModelLane sites converted to resolve_lane; hand-rolled test result
  namespaces consolidated onto mock_completion_result; stale synth-test
  module docstring re-pointed.
2026-07-13 08:48:27 -07:00
Patrick Buckley 7e07f2ea93 feat(core): phase 2 — every single-shot lane speaks Turn IR (#827)
create_completion now has exactly one caller: model_turn. The π-side
lanes migrate off hand-built OpenAI dicts:

- _utility_completion (title gen, compaction, web-fetch extraction)
  takes list[Turn] and runs the session's primary lane through
  model_turn; its three call sites build Turn.system/Turn.user.
- perception.describe builds a by-reference trajectory (AttachmentRef +
  the prebuilt parts via resolve_attachments, reintroduced on
  model_turn with its first caller and pinned by tests) — Turn IR never
  carries inline media bytes, matching the main loop's wire path. Its
  temperature=0.2 pin is gone (house rule).
- eval HeadlessSession's loop lowers system prompts through the
  turns_from_dicts bridge and appends result.turn; the parallel-call
  cap now also drops the native lane on a capped turn (a capped mirror
  with a full native lane would replay orphan tool blocks).
- optimizer: all five sites (diversifier, observer, analyst loop,
  tool optimizer, prompt optimizer) build Turn IR through per-function
  lanes; every temperature pin (0.8/0.3/0.3/0.3/0.6) removed per house
  rule — sampling behavior belongs in the model's configuration.

Test mocks move to the shared full-shape helper where the model_turn
re-ingest now runs; perception/attachment tests assert the
by-reference placeholder + resolver contract instead of inline parts.
2026-07-13 08:48:27 -07:00
Patrick Buckley b0937683ae fix(model-turn): apply the #827 phase-1 review round
Behavior fixes, per review + house rules:

- Judges no longer pin temperature=0.0 — the lane inherits the model's
  configured temperature (ModelConfig.temperature via resolve_lane), and
  model_turn omits the kwarg entirely when nothing resolves. House rule:
  code never pins a temperature; modern models often misbehave below
  1.0, so the model's configuration is the source of truth. This also
  dissolves the extra_body-overrides-judge-pins collision: operator pins
  reaching the judge lane is the doctrine working.
- Session-fallback judges inherit the session's registry alias
  (session_model_alias threaded from ChatSession), so the registry-
  resolved extra_params / replay flag / vLLM attach apply on the default
  judge.model-unset configuration instead of only on explicit aliases.
- Blank-id native lanes are repaired, not dropped: model_turn backfills
  the manufactured mirror ids into blank-id native client tool blocks
  pairwise (the #825 1:1 ordering invariant), so thought_signature
  survives Google's blank-id compat responses and thinking blocks keep
  their continuity on blank-id locals. Only blank ids are ever written —
  a provider-assigned id (possibly signature-covered) is never touched —
  and any pairing mismatch falls back to the #825-converged total drop.
- model_turn(mint=...) without wire_id_map now raises: minted ids are
  unrestorable without the recovery map, and the two parameters were
  independently optional by accident.
- Lane resolution reads ONE defensively-fetched ModelConfig
  (_get_config_or_none): a registry hot-reload mid-resolution can't mix
  config generations, and an alias that raced away degrades each facet
  to its miss behavior instead of aborting a judge constructor into the
  silent session-model downgrade.

Extraction hygiene, per review:

- Dead session wrappers deleted (_resolve_server_type,
  _maybe_synth_reasoning_block, _get_server_compat) and their tests
  re-pointed at the module functions; the stranded reasoning-types
  comment and two stale doc pointers cleaned up.
- Speculative extra_headers / resolve_attachments pass-throughs dropped
  from model_turn until a caller lands (phase 2/4 reintroduces them
  with their lane).
- _server_type_of(cfg) is the one reader of server_compat.server_type;
  the vLLM-attach gate and resolve_server_type both use it, retiring
  the change-both-readers discipline comment.
- dataclasses import hoisted; module docstring restated as the durable
  contract (grep callers for coverage) instead of a rotting snapshot.
- mock_completion_result shared in tests/_session_helpers.py — one
  definition of "every field the re-ingest reads".
2026-07-13 08:48:27 -07:00
Patrick Buckley 54dd4ed50a feat(judge): both judges speak Turn IR through model_turn (#827)
The intent judge's evidence loop and the output-guard's single shot now
build list[Turn] and call model_turn — the hand-built OpenAI-dict
message construction is gone, and with it the judges' private
interlingua. The assistant turns they append carry the provider-native
lane, so the loop keeps reasoning continuity across its own turns.

That is what unblocks Gemini: thought_signature rides provider_blocks
and is reconstructed by the Google adapter's fidelity swap, so the
provider_name == "google" tool-skip is deleted — the Gemini judge runs
the same evidence-tool loop as every other provider instead of
degrading to a single-shot, tool-blind verdict.

judge.py's _resolve_model_capabilities mirror (#826) is deleted; both
judges resolve capabilities through the shared lane resolver, and each
evaluation builds a ModelLane (fresh client, constructor caps,
registry-resolved extra_params + live flags). The shared resolver
inherits the mirror's defensive non-dict capabilities check — without
it a malformed registry row would silently downgrade a judge to the
session model instead of just skipping the overrides.

Judge calls now resolve extra_params and replay_reasoning_to_model
from the registry like every other lane (previously: never sent, and
the protocol's back-compat default respectively).

Test mocks grow the CompletionResult fields the model_turn re-ingest
reads (provider_blocks, reasoning); alias-registry mocks wire
get_config, which the unified resolver uses.
2026-07-13 08:48:27 -07:00
Patrick Buckley ab35eb4215 refactor(core): extract model_turn, the shared plant-call primitive (#827)
Lower-and-sample is now one surface: core/model_turn.py owns the
Turn-IR lowering seam (dicts_from_turns -> sanitize_tool_call_arguments
-> restore_provider_tool_ids -> Phase 5 vLLM attach), the provider call,
and the re-ingest to an assistant Turn carrying the native lane.
ModelLane binds a resolved lane (provider, client, model, capabilities,
extra_params) and carries the registry so live operator toggles
(replay-reasoning, vLLM attach) keep re-resolving per call.

The task-agent seam is the first client: _run_agent builds a ModelLane
and calls model_turn with a mint closure; the inline mint/back-fill/
finalize block collapses to appending result.turn. Session capability/
extra-params/replay/finalize helpers become delegates to the module
functions, so lane resolution has exactly one logic path.

model_turn is policy-free by contract: retry, deadlines, tool
execution, and usage recording stay with each caller.

Two agent-path tests move their replay-flag pin to the module seam
(one had gone vacuous against the session wrapper); _record_aux_usage
now takes UsageInfo rather than a CompletionResult.
2026-07-13 08:48:27 -07:00
renovate[bot] 9b01d8e569 chore(deps): update dependency typescript to v7 2026-07-13 04:57:36 -07:00
renovate[bot] 4878f16475 chore(deps): update github actions 2026-07-13 04:57:21 -07:00
Patrick Buckley b2b8b6f65e fix(mcp): schedule node reload after admin write instead of blocking on it
The auto-notify added in the prior commit awaited _notify_nodes_mcp_reload
inline in create/update/delete, coupling each admin write's latency — and
success — to cluster reachability: on a large cluster with slow/unreachable
nodes the write could hang up to ceil(nodes/fan_out_limit)*30s behind the
fan-out, and a post-commit fan-out error would 500 a write that already landed.

Schedule the fan-out as a BackgroundTask that runs AFTER the 200 instead — the
"trigger, not drain" contract already used by _cascade_cancel_to_children — so
the write's response is never blocked on, nor failed by, the fan-out. The
pre-existing registry-install path is converted the same way for consistency.

There is no periodic node->DB reconcile, so a node that misses the reload serves
a stale MCP catalog until the next POST /reload. The background _run therefore
logs any unreached node (or a systemic fan-out fault) at WARNING — visible at
the default INFO level — rather than swallowing it; the per-node status view
also surfaces the divergence. A non-2xx reply from a node's reload/action
endpoint now counts as a failure (raise_for_status) rather than a reached node,
so neither the WARNING nor the operator /reload results miss a 5xx node.

Revert the getattr None-guard on _notify_nodes_mcp_reload: it turned the
operator-triggered POST /reload into a silent success ({} with 200) when the
fan-out infra was absent — a fail-loudly violation — and diverged from the
unguarded sibling _notify_nodes_mcp_action. The helper is drain-style again,
awaited only by /reload (which must surface fan-out failures); writes go through
the best-effort scheduler.

Tests: assert the reload is NOT scheduled on a delete/update 404 or a create
secret-store 503; that an unreached-node, raising, or non-2xx fan-out is logged
at WARNING / recorded as an error; and that operator POST /reload fails loudly
(500) without fan-out infra.
2026-07-12 19:03:35 -07:00
Patrick Buckley d6ccc5ed17 fix(console): show 'per-user' for idle pool MCP servers, not 'connecting'
oauth_user/oauth_obo servers hold no cluster-level session — they connect per-user on demand — so the admin status pill rendered 'connecting'/'idle', which reads as broken, when zero warm users is the normal resting state. Render 'per-user' for pool-backed servers instead.
2026-07-12 19:03:35 -07:00
Patrick Buckley b3cd91f1a0 fix(mcp): auto-notify nodes on admin create/update/delete
admin_create/update/delete_mcp_server wrote to the DB but never told nodes to reconcile — only the registry-install path and the explicit /reload did — so a programmatic create/edit/delete was inert on nodes until a manual reload (and the mid-session re-prime self-heal never fired). Call _notify_nodes_mcp_reload after each write, mirroring registry-install; also make that helper best-effort (skip when the cluster fan-out infra is absent) so a write can't 500 on it.
2026-07-12 19:03:35 -07:00
Patrick Buckley 9391509e85 fix(oidc): trust Entra's graph.microsoft.com userinfo out of the box
Microsoft Entra's discovery document advertises userinfo_endpoint on graph.microsoft.com — a host distinct from the login.microsoftonline.com issuer — so discover_oidc's cross-host guard rejected it and disabled OIDC unless the operator set trusted_endpoint_hosts. Add login.microsoftonline.com to the built-in KNOWN_TRUSTED_OAUTH_ENDPOINT_HOSTS allow-list (mirroring the Google entry) so Azure AD OIDC works with no extra configuration. Surfaced by the live obo integration test.
2026-07-12 19:03:35 -07:00
Patrick Buckley 49f2266e20 fix(mcp): address review of the re-prime self-heal
- detect an in-place oauth_user<->oauth_obo flip by diffing the pool servers' (name -> auth_type) view instead of names only, so a migrated server re-primes active sessions (a name-only diff saw the same name on both sides and missed it);
- guard prime_user_pools per-user so one scheduling failure can't propagate out of reconcile_sync (500 the reload) or skip the remaining users;
- log what was SCHEDULED (prime is fire-and-forget and no-ops for credential-less users / a down loop), not 're-primed', and take an int changed-count instead of a set whose name falsely implied per-server scoping.
2026-07-12 19:03:35 -07:00
Patrick Buckley d1de602b78 docs(mcp): align token-encryption + mint docstrings with oauth_obo
Startup key-enforcement counts ALL user-scoped auth types (oauth_user and oauth_obo, per is_user_scoped_auth), and the entra mint leg always carries scope=<audience>/.default (per-server oauth_scopes is ignored on that leg). The docstrings named only oauth_user / left the scope behavior ambiguous. Comment-only; no behavior change.
2026-07-12 19:03:35 -07:00
Patrick Buckley 86253a3b07 fix(mcp): re-prime active sessions when a pool server appears mid-reconcile
prime_user_pools runs once at ChatSession start, so an oauth_user/oauth_obo server registered while a session is already open never reached it — and for oauth_obo (no consent flow) priming is the ONLY path tools take into the catalog, so a mid-session registration stayed invisible until the session restarted. reconcile_sync now diffs the pool-server name set and re-primes every active session's user when a new server appears; idempotent (skips already-warm pools) and a no-op for users without a captured credential.
2026-07-12 19:03:35 -07:00
Patrick Buckley 530aaa6632 refactor(mcp): drop dead grant-profile recompute after runtime rediscovery
Round-12 review follow-up (no correctness findings). obo_grant_profile is
a static config field that OIDC runtime rediscovery never changes, so
recomputing profile/mint after maybe_rediscover_oidc was dead work that
implied the grant profile could change across a heal (it cannot). Re-read
only the discovery-derived state (enabled / token_endpoint).

The remaining review findings are accepted by design: the credential-
rotation CAS's sub-millisecond read->write window (self-heals on next
login; a full fix needs SELECT FOR UPDATE or a version column) and the
per-server delete loop on identity deletion (the per-server try/except
buys partial-failure resilience a single bulk delete would not).
2026-07-12 19:03:35 -07:00
Patrick Buckley 8a1efaf55e perf(mcp): skip redundant priming credential read; dedup sweep clear bookkeeping
Round-11 review follow-up — no correctness findings; efficiency/DRY cleanups.

- Session-start priming already confirms the captured credential exists
  once for all of a user's obo servers, but each per-server
  get_obo_access_token_classified re-read it pre-lock (N+1 reads). The
  priming path now passes credential_present=True so the per-server
  existence read is skipped; other callers keep their own read.

- _clear_pending_consent_best_effort (the sweep clear path) now routes
  through _mark_pending_consent_cleared instead of inlining the
  prune-then-stamp step, matching the helper's documented contract so the
  two DB-confirmed clear sites can't drift.

The per-dispatch pending-consent clear's DELETE volume and the removed
interactive.js no-consent-URL fallback are left as-is: the former is the
deliberate, TTL-bounded cost of cross-node badge self-heal, and the
latter is unreachable for oauth_user (which always carries a consent_url)
and intended for oauth_obo (which has no per-server consent flow).
2026-07-12 19:03:35 -07:00
Patrick Buckley 08d765a74a fix(mcp): record effective obo scope so entra .default isn't cached as narrow
Round-10 review follow-up.

- A server scoped under obo_grant_profile=rfc8693 that survives a switch
  to the entra profile mints <audience>/.default (the entra leg cannot
  honor per-server oauth_scopes), but the cache row recorded the
  configured narrow scope — so _is_fresh_obo_cache_row kept serving the
  broad .default bearer believing it was narrow, and a scope change that
  can't apply under entra looked like it had. The freshness gate and the
  cache row now record the EFFECTIVE scope the leg actually mints ('' for
  entra, the configured scope for rfc8693); the raw scope is still passed
  to the mint so the entra leg's "oauth_scopes ignored" warning still
  surfaces the misconfigured leftover.

Cleanup: the R9-5 single-per-mint client made every token-POST caller pass
a non-None client, so the transient-client fallback in _hardened_token_post
was dead and two doc/comment blocks described the opposite of the real
behavior. Removed the dead branch, tightened the http_client typing across
the mint chain, and corrected the docs.
2026-07-12 19:03:35 -07:00
Patrick Buckley 903cf5f72d fix(oidc/mcp): login-path self-heal, guard mint persist, CAS credential rotation
Round-9 review follow-up.

- Runtime OIDC rediscovery was triggered only from the obo mint path,
  which needs an already-signed-in user — so a single-node install (or
  one where every node booted during a transient IdP outage) kept OIDC
  LOGIN dark until an operator restart. The authorize and callback
  handlers now trigger maybe_rediscover_oidc before their enabled gate,
  so login self-heals too.

- A transient storage error on the obo mint-cache write (delete+create)
  raised out of get_obo_access_token_classified, discarding a valid
  just-minted token and breaking the classified-result contract. The
  cache write is now best-effort — the working bearer is returned and the
  next dispatch re-mints. Likewise the runtime rediscovery's discover_oidc
  call is wrapped in except Exception (like the boot path) so an
  unexpected discovery error can't escape the mint's contract.

- Login-time credential capture could race an in-flight mint on a
  strict-rotation IdP: the mint's rotation write-back would clobber the
  fresh login refresh token with a stale rotated one. The rotation
  write-back is now a value compare-and-swap against the token the mint
  read, so a credential a concurrent login just refreshed is not
  overwritten.

Cleanup: the rfc8693 mint now opens one transient httpx client for the
whole mint so the token-exchange leg reuses the refresh leg's connection
instead of a second TLS handshake.
2026-07-12 19:03:35 -07:00
Patrick Buckley ec079f0df3 fix(oidc/console): unblock obo edits when OIDC off; latch config-invalid rediscovery
Round-8 review follow-up — two correctness follow-ons from the round-7
rediscovery/console-gate fixes, plus two cleanups.

- The console obo write gate ran the OIDC-deployment checks on EVERY
  update, so once OIDC was operator-disabled any edit of an existing
  oauth_obo server — including the natural remedy of setting
  enabled=false — was rejected 400, leaving DELETE as the only way out.
  The deployment-level checks (encryption key, OIDC enabled/configured,
  capture opt-in, valid grant profile) now run only when a write is a NEW
  obo enablement (create or flip INTO obo); a same-type edit keeps only
  the per-server validity checks (audience required, entra-scope reject),
  so an operator can always disable or edit an existing obo server.

- Probing rediscovery with enabled forced True carried the retryable boot
  flag into discover_oidc, whose config-error branches returned enabled=
  False without clearing it, so a config-invalid IdP (an endpoint failing
  SSRF/same-origin validation) re-probed every 60s forever. The config-
  error branches now latch discovery_retryable=False (terminal), and
  maybe_rediscover installs that terminal config so the node stops
  probing; the transient fetch/degraded branches keep retrying.

Cleanups: fold the obo missing-expires_in fallback into
_expires_at_from_response via a default_ttl_seconds param (one owner of
the stored-expiry format), and drop the redundant audience-change
inequality already guaranteed by the no-op normalization (matching the
sibling scopes_changing).
2026-07-12 19:03:35 -07:00
Patrick Buckley af56170be6 fix(oidc/mcp): make runtime OIDC rediscovery actually work; preserve oauth_user paths
Round-7 review follow-up.

- The runtime OIDC re-discovery feature was dead code: discover_oidc
  PRESERVES the input config's `enabled` flag on success (only
  load_oidc_config ever sets it True), and maybe_rediscover_oidc always
  probed from the disabled boot config, so a successful rediscovery still
  returned enabled=False and the config swap was unreachable — the whole
  boot-outage auto-heal never worked. It now probes with enabled forced on
  so the flag is a reliable success signal. The unit test that "covered"
  this was mocking discover_oidc to return enabled=True, masking the bug;
  it now drives the real discover_oidc through a mocked HTTP discovery GET.

- The console never runs runtime rediscovery, so a transient discovery
  failure at console boot made every oauth_obo server un-editable and
  un-disable-able. The write gate now accepts a discovery_retryable config
  (OIDC configured, discovery transiently down) and rejects only a
  genuinely absent OIDC.

- The first rediscovery probe was suppressed for ~60s after host boot
  because the "last probe" timestamp defaulted to 0.0; it now uses a None
  sentinel for "never probed".

- Two behavior-preservation fixes for the pre-existing oauth_user path:
  the shared hardened token-POST no longer escalates oauth_user oversized
  error bodies (that status-based classification is opt-in for the obo
  legs only), and the token_revoked audit fires unconditionally for
  oauth_user again (a refresh failure means a real grant died) while
  staying delete-gated for obo to avoid revocation rows for tokens that
  never existed.

Cleanups: drop a throwaway set allocation in the pool-emptiness check,
compute the create handler's cleaned OAuth text once, remove a dead
no-op pop with a false comment, and simplify the cleared-map prune to two
non-overlapping passes.
2026-07-12 19:03:35 -07:00
Patrick Buckley 50d0ac9833 fix(mcp): classify oversized token error by status; dedup transition/obo-scan
Round-6 review follow-up — no CONFIRMED correctness bugs; one plausible
edge case and four DRY/drift cleanups.

- The shared hardened token-POST raised its 64KB body-size guard with the
  default TRANSIENT class before the non-200 was classified, so a permanent
  dead-grant whose error body exceeded the cap looped "please retry"
  forever and never escalated. An over-sized client-error response is now
  classified AMBIGUOUS by status (without reading the over-sized body), so
  it still escalates to the honest re-login/admin remedy after the streak.

- The admin update handler re-derived the is_flip predicate inline in the
  three token-purge guards (and computed target_auth / auth_type_now as two
  names for the same effective auth type). Both now reuse the single
  is_flip / target_auth derivations, so the purge guards and the column
  scrub can't desync on what counts as a flip.

- The oauth_obo server-name scan was hand-rolled in two places (the
  connections-list filter and the identity-delete cache purge) with
  divergent null handling. Extracted obo_server_names(storage) so a change
  to how sign-in-passthrough is recognised can't leave one path silently
  missing servers.

- Inlined the two single-use _*_detail wrappers into direct
  _pool_error_detail calls, keeping named wrappers only for the
  multi-caller situations.
2026-07-12 19:03:35 -07:00
Patrick Buckley 09aa50b7a1 fix(mcp): close obo auth-column leak, capture gate, and cooldown classification
Round-5 review follow-up — three CONFIRMED (one security) plus two
correctness issues, all traceable to earlier fixes in this branch.

SECURITY: the round-2 redesign gated the "scrub OAuth columns this
auth_type doesn't use" on is_flip, replacing the old unconditional
scrub. A same-type static/none/obo edit could then inject an
oauth_authorization_server_url that survived a later flip to oauth_user
(which uses that column) and redirected every consenting user's OAuth
traffic to an attacker AS. The scrub is now applied on EVERY write, and a
flip into oauth_user recomputes the oauth_user-only columns from the
request so a stale value can't carry in — the persisted OAuth columns
are once again a pure function of the target auth_type.

- The oauth_obo write gate now also requires capture_user_credential to
  be enabled: without it, login persists no credential and every dispatch
  returns "missing" with a remedy that can never succeed — the permanent
  misconfig the gate exists to reject.

- A permanent obo mint failure arms the cooldown (its shared credential
  survives the per-server revoke), but the in-cooldown short-circuit
  reported it as a retryable transient for the whole window, flapping
  against the honest re-login/admin affordance. The backoff state now
  records whether the arming failure was permanent, and the short-circuit
  surfaces the matching classification.

- The ambiguous-escalation revoke cleared the cooldown without re-arming;
  for obo (surviving credential) that let the next dispatch immediately
  re-mint against the still-failing IdP. It now re-arms the same terminal
  backstop the permanent branch has.

- The force-refresh reuse gate keyed on the cache row's 1-second `created`
  time, which couldn't tell a concurrent peer's fresh mint from the
  caller's own just-rejected token minted in the same second — so a retry
  could re-serve the rejected bearer. It now decides by token identity
  (the under-lock row differs from the pre-lock one), preserving the
  single-flight reuse while never re-serving a rejected token.

Also: guard _pool_error_detail's str.format so placeholder-free copy
can't raise inside the error renderer, and note why the connections-list
classifies obo rows by authoritative auth_type on that cold path.
2026-07-12 19:03:35 -07:00
Patrick Buckley c53bd464d0 refactor(mcp): dedup obo credential decrypt, cooldown arming, pool set, error copy
Round-4 review follow-up — no correctness findings; these are the four
cleanups it surfaced.

- The obo mint path decrypted the captured IdP refresh token twice per
  mint: once pre-lock only to test presence, then again under the lock.
  The pre-lock presence check now uses the raw existence read (no
  decrypt), mirroring the priming path; the single authoritative decrypt
  happens under the lock. Removes N throwaway decrypts per user at
  session-start priming across N obo servers.

- The "arm the per-(user,server) cooldown" idiom was written inline at
  four failure sites. Extracted _arm_cooldown (returns the backoff state
  so the streak-mutating callers reuse it), so a change to how backoff
  works is one edit.

- The oauth_user|obo pool-membership union was rebuilt inline at three
  iteration sites. Added a _pool_server_names property, the set-level
  counterpart to _is_pool_server, so a future third pool-backed auth type
  is registered in one place.

- The four per-situation remediation-copy helpers each repeated the
  oauth_user-vs-obo branch. Consolidated the copy into one
  (auth_model, situation) table behind _pool_error_detail — the single
  place the auth-model decision is made — so a dispatch site can't pair a
  situation with the wrong auth model's copy (the wrong-remediation bug
  class this review caught repeatedly). The named helpers remain as thin,
  tested wrappers.
2026-07-12 19:03:35 -07:00
Patrick Buckley 6d80051925 fix(mcp): decouple capture key guard from OIDC discovery; bound obo token TTL
Round-3 review follow-up.

- The startup guard that refuses to boot without a token-encryption key
  when capture_user_credential is enabled was gated on oidc_config.enabled.
  Enabled reflects whether OIDC *discovery* succeeded, which is transient:
  a node that boots while the IdP is unreachable comes up enabled=False,
  so the guard was silently skipped exactly when it was needed, and runtime
  rediscovery would later re-enable OIDC with the first login persisting a
  refresh token and no key. Gate on the operator's capture opt-in alone
  (a static config value), independent of discovery state.

- An obo mint response omitting the RFC 8693-optional expires_in cached
  expires_at=NULL, which the freshness gate reads as never-expiring — fine
  for opaque oauth_user tokens, wrong for a short-lived minted token, which
  would then be served indefinitely and defeat audience/scope narrowing
  that relies on TTL turnover. Fall back to a bounded default expiry.

- The empty-token fallback in the shared pool-lookup error mapping now uses
  the auth-model-aware consent detail like its sibling missing branch, so an
  obo row never shows per-server-consent copy with a null consent URL.

- Documented the _build_consent_url invariant at the chat error-card render
  gate: oauth_user rows always carry a consent URL, so gating the Connect
  button on its presence never hides a needed button for them; the button's
  absence for sign-in passthrough is intended (the detail text is the
  affordance).
2026-07-12 19:03:35 -07:00
Patrick Buckley d2e69ca527 fix(mcp): coherent obo auth-type carry-over + honest error affordances
Round-2 review follow-up. The headline is a redesign of the OAuth
column carry-over so scopes/audience can no longer leak or vanish across
an auth-type flip:

- oauth_audience and oauth_scopes keep their meaning only WITHIN an auth
  type (a resource indicator vs. an IdP app id; AS-consent scopes vs. an
  rfc8693 exchange scope). On any oauth_user<->oauth_obo flip they are
  now recomputed from the request (present -> value, absent -> NULL) and
  never carried from the old row. A shared _oauth_columns_to_clear policy
  drives both the create and update handlers. No-op normalization of a
  re-sent equal value applies only to same-type edits.
- The console form clears both semantic fields when the auth type
  changes and always submits the visible values; the previous
  "omit unchanged scopes" logic collided with the backend's flip
  handling and could silently drop or carry scopes.

Write-time validation now rejects oauth_obo rows that can never mint —
OIDC disabled/unconfigured, or an invalid obo_grant_profile — instead of
letting them surface per-dispatch as a retryable transient that never
heals.

Honest failure affordances for sign-in passthrough (no per-server
consent flow exists):

- the token_revoked audit fires only when a row was actually deleted, so
  a permanent mint rejection against a surviving credential no longer
  appends a bogus revocation on every post-cooldown dispatch/prime;
- the 403 insufficient-scope detail and the chat error card's action
  button are now auth-model-aware — obo errors point at the
  administrator rather than a dead-end re-consent, and the Connect button
  renders only when a real consent URL is present;
- the read-side freshness gate now enforces scopes as well as audience,
  so an rfc8693 scope narrowing takes effect on the next dispatch even if
  the best-effort admin cache purge failed.

Cleanups: the five decrypt-failure result constructions collapse into
_decrypt_failure_result; the cleared-pairs TTL bookkeeping into
_mark_pending_consent_cleared; drop the dead USER_SCOPED_AUTH_TYPES
re-export from mcp_oauth; correct the now-bidirectional oidc<->mcp_oauth
lazy-import note. Docs updated for the flip semantics and the OIDC
prerequisite.
2026-07-12 19:03:35 -07:00
Patrick Buckley 32c76499fa fix(mcp): harden obo mint path and admin lifecycle after review
Mint engine: guard the credential-rotation persist so a storage blip
cannot escape the classified-result contract mid-mint (and cannot brick
the user's other obo servers on strict-rotation IdPs); stop borrowing
the login flow's httpx client across event loops — mints use a transient
per-request client (obo_http_client remains as a test seam); retry OIDC
discovery at runtime (cooldown-gated, single-flight) so a node that
booted during an IdP outage can mint again without a restart; key the
under-lock force-refresh reuse gate on created, which delete+create
makes the mint time (obo rows never set last_refreshed, so the copied
oauth_user gate never fired and serialized waiters each re-redeemed).

Cross-node consent badges: the cleared-pairs set becomes a TTL map with
bounded growth, so a badge written by another node after this node's
last clear self-heals within one TTL window instead of surviving until
a restart.

Admin lifecycle: purge the mint cache when oauth_scopes changes on an
obo row (an rfc8693 privilege reduction now applies immediately, like
audience changes); normalize no-op scope/audience re-sends out of
updates — the admin form re-submits pre-filled fields on every save,
which both re-triggered purges and made entra-profile rows with legacy
scopes un-editable; make flip-into-obo scope handling grant-profile
aware (entra clears the carry-over, rfc8693 honors the request); clear
obo-era audience/scopes when flipping back to oauth_user (the IdP-side
app identifier is not a resource indicator); mirror the same column
policy in the create handler.

Revocation honesty: hide obo mint-cache rows from the user connections
list and refuse the per-server disconnect with 409 — deleting the row
returned 204, audited token_revoked, and then session-start priming
silently re-minted from the surviving captured credential.

Console form: keep the audience-from-URL autofill off for sign-in
passthrough (the audience there is an IdP application identifier, and
the prefilled URL passed every validation layer then failed every
mint); clear the autofill artifact when switching modes; omit unchanged
scopes from submissions.

Dispatchers: route tool/resource/prompt through one shared lookup-error
mapping and an auth-model-aware 401-exhausted detail (obo users are no
longer pointed at a consent flow that does not exist). The consent-url
audit count drops 13 → 7: the three per-dispatcher mapping copies
collapsed into _pool_lookup_error.

Priming: skip all obo servers for users with no captured credential via
one existence SELECT (previously three reads per server per session).

Also: USER_SCOPED_AUTH_TYPES now lives in storage._protocol so the
backend SQL predicates share the application layer's set; docs describe
the actual purge-on-transition behavior (the orphan-and-reactivate
claims were wrong); the entra e2e setup script no longer aborts
silently under set -e with suppressed stderr.
2026-07-12 19:03:35 -07:00
Patrick Buckley 44e9d46e40 fix(mcp): address pre-push review — obo scope/audience/priming defects
Frontend↔backend interaction bugs the backend-only rounds couldn't see:
- flip oauth_user->oauth_obo: the admin form re-submits the pre-filled
  oauth_user scopes, so the flip-clear (gated on 'oauth_scopes' not in
  body) was skipped -> rfc8693 mints broke permanently. Clear now
  compares to the existing value, robust to the re-send.
- entra edit-lockout: update validated the MERGED scopes, so a
  pre-existing scoped obo row under the entra profile became un-editable
  (every PUT 400'd). Reject only when the request actually SETS scopes.
- flush-cache button never rendered: consented_users_count is now
  populated for oauth_obo rows too, not just oauth_user.

Mint engine + priming:
- audience guard: a cached token minted for a since-narrowed audience is
  no longer served (extracted _is_fresh_obo_cache_row, used pre/post-lock,
  checks refresh-less + audience-match + fresh). _persist_obo_cache_row
  now delete+creates so the row's audience column tracks the mint (a
  plain update kept the stale audience -> re-mint loop).
- obo session priming passes revoke_ambiguous_escalation=False (new param
  threaded through get_obo_...), so an IdP wobble during a bulk prime
  can't escalate-revoke obo cache rows cluster-wide.

Cross-node + lifecycle:
- pending-consent success-clear now clears once-per-failure-cycle via a
  _pending_consent_cleared set (was gated on 'we wrote it' -> never fired
  cross-node/after-restart -> stale badge). Still no per-call SQL.
- identity-unlink cache purge: per-server try/except so one failure
  doesn't leave other servers' bearers un-purged.
- entra ignored-scopes: warn once per audience (was per-mint flood ->
  downgraded to debug -> no signal on a profile switch).
- entra_setup.sh writes single-quoted .env values (secret may contain $).

+6 regression tests. 1892 mcp/oidc/console tests green; mypy clean.

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley 3e88c54751 test(mcp): check in oauth_obo e2e harnesses under scripts/obo-e2e
Manual (non-CI) harnesses that exercise the real oauth_obo mint path
against a live IdP, kept for future validation of the feature:

- entra_e2e.py: real Entra tenant, one interactive sign-in, drives
  get_obo_access_token_classified -> _obo_mint_entra (E1-E7)
- keycloak_e2e.py + .sh: ephemeral Keycloak, fully headless, drives the
  rfc8693 leg (refresh grant -> token exchange)
- entra_spike.py: raw-OAuth wire probe (pre-implementation reference)
- entra_setup.sh: creates the Entra spike app registrations
- .env.example template; real creds stay in a gitignored .env

Both legs pass E1-E7 (mint + aud, cache hit, single-credential->multi-
audience, rotation write-back, force_refresh, unconsented->credential
survives, flush->re-mint). Not wired into CI.

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley 891c8b1785 docs(mcp): operator guide for oauth_obo single-credential sign-in passthrough (slice 5)
Adds the oauth_obo section to docs/mcp-oauth.md:
- when to use it vs oauth_user (mode table row)
- deployment config ([oidc] capture_user_credential + obo_grant_profile,
  encryption-key requirement)
- per-IdP setup: Entra (delegated permissions + admin consent, plus the
  verified admin-consent-propagation AADSTS65001 gotcha) and Keycloak
  RFC 8693 (standard token exchange + audience client scopes)
- revocation & custody model: identity-unlink cuts a user off (credential
  + cache purge); flush-cache is an honest re-mint, not a revoke; per-server
  revocation is IdP-governed
- auth-type-transition + troubleshooting table rows for obo
- interim #682 note (Entra pre-authorized-clients removes the second
  consent for plain oauth_user, tenant-config only)

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley 202c39e634 feat(console): oauth_obo option in the admin MCP server form (slice 4 frontend)
Operators can now select sign-in passthrough (oauth_obo) in the console,
not just via the API:

- new 'Sign-in passthrough' auth-type radio with plain-language copy
  ('uses your org login - no separate connect')
- the shared OAuth fields block hides the oauth_user-only inputs
  (AS URL / registration / client id / secret) for obo and shows just
  the audience (marked required) plus scopes (hinted rfc8693-only), with
  an explanatory note
- client-side audience-required validation (inline error, not a 400)
- edit-populate + reset handle the new radio
- server list: obo servers get an honest 'flush cache (N)' action
  (drops minted tokens -> re-mint) instead of connect/bulk-revoke, with
  a confirm dialog that states it does NOT cut off access (that is
  IdP-governed / identity-unlink)

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley e5f8453e1a fix(mcp): complete oauth_obo revocation lifecycle + fix hot-path regression (follow-up review)
Addresses the high follow-up review of the first fix round:

Revocation lifecycle (the review's dominant theme):
- identity-unlink now purges the user's minted obo cache rows in addition
  to revoking the credential, and the response/audit report the actual
  effect (credential + N cache rows) instead of a blanket revoked=true;
  warmed-session residual (bounded by token TTL) documented
- bulk-revoke on obo is now an honest cache-FLUSH: distinct audit event
  (obo_cache_flushed) + response effect=cache_flush_remints, since the
  shared credential survives and the next dispatch re-mints (oauth_user
  keeps its durable revoke semantics)
- changing oauth_audience on a pool-backed row now purges cached tokens
  (audience is the token binding), like URL/name/auth_type changes
- flipping oauth_user->oauth_obo now clears the stale AS-consent scopes
  (else rfc8693 sends them -> invalid_scope loop); write path rejects
  oauth_scopes under the entra profile (it mints <audience>/.default)
- a cache row bearing a refresh token is never served as an obo token
  (guards the cross-node purge-vs-refresh race)

Self-inflicted regression:
- _clear_pending_consent_sync is now gated on an in-memory
  _pending_consent_written hint, so the common successful-dispatch path
  issues ZERO SQL (was an unconditional per-dispatch DELETE)

Observability + cleanups:
- restore the obo_mint_rejected log carrying the IdP error text (the
  shared-helper unification dropped it); event names passed as whole
  literals so alerting can grep them
- persist_rotation typed Callable[[str], Awaitable[None]] (was Any)
- _prime_one branches on _obo_server_names (no pre-lookup SQL for
  oauth_user)
- removed now-dead any_oauth_user_mcp_servers (3 impls + tests)

+13 regression tests. Full mcp/oidc/console suite 1888 green; mypy clean.

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley d02b9c0cf0 fix(mcp): oauth_obo credential lifecycle + pending-badge coverage (P1 per review)
- credential revocation (440): admin OIDC identity-unlink now deletes the
  captured IdP credential too (via delete_oidc_credential, previously
  zero callers), so a deprovisioned user stops minting — audited with
  obo_credential_revoked
- pending-consent badge gate (3539): new any_user_scoped_mcp_servers
  (oauth_user OR oauth_obo) replaces the oauth_user-only gate, so an
  obo-only install no longer short-circuits the badge to {pending: 0}
- pending-consent clear (5966): dispatch SUCCESS now clears the pending
  row (auth-blind _clear_pending_consent_sync) — the only clear path that
  covers obo, whose rows the token sweep (skips obo) and consent callback
  (obo never runs) would otherwise never clear
- test:50: strengthened the created-preservation assertion to plant a
  distinctly-past created via SQL so a reset is actually detectable

+4 tests (obo/user-scoped gate). NOTE: finding 1992 (orphan cache row on
concurrent delete-during-mint) accepted as bounded residual — the orphan
is a short-lived access-token cache row with NO refresh token, useless
without the deleted credential and self-expiring; a full fix needs FKs or
a delete-spanning lock. Tracked for follow-up.

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley 429a7fd13c fix(mcp): prime oauth_obo pools at session start (fixes inert feature)
The review's most severe finding: nothing warmed oauth_obo pools, so
their tools never entered any per-user catalog and the documented 'mint
on first dispatch' was unreachable (the model can't dispatch a tool it
can't see) — the whole feature was dead in chat.

prime_user_pools now iterates both pool-backed registries. _prime_one
fetches server_row first, then routes oauth_obo through
get_obo_access_token_classified (mints from the captured credential;
missing credential → skipped, the re-login rail handles it) and
oauth_user through its own path unchanged. _rebuild_user_tool_map is
already auth-type-blind, so a warmed obo entry surfaces its tools.

+2 regression tests (obo routed through mint + warmed; skipped cleanly
when the user has no credential).

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley d84393c25c fix(console): widen admin MCP surface for oauth_obo (P0/P1 per review)
- write-time validation (_enforce_oauth_obo_requirements): reject an
  oauth_obo row with no oauth_audience (400) or no encryption key (503,
  else it SystemExits the cluster at next boot) — at the save choke
  point, not per-dispatch (findings 10137/10163)
- update handler no longer nulls oauth_audience/oauth_scopes for
  oauth_obo (it needs them); clears only the oauth_user-only columns
  (10344)
- auth_type-transition purge now covers every pool-backed transition,
  including oauth_user->oauth_obo (was skipped: old per-server-AS refresh
  tokens leaked into the mint cache + left a live grant at the old AS
  unrevoked) and oauth_obo->static/none (10326)
- URL-change purge + https enforcement + client-secret clear now apply to
  oauth_obo, not just oauth_user (10339)
- bulk-revoke accepts oauth_obo — the documented remediation for the
  stale rows a flip leaves behind (10705)

+6 console tests (obo audience/key required, happy path, flip-purge, obo
bulk-revoke).

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley 17c44305d6 fix(mcp): harden oauth_obo mint engine per max review (P0 security core)
Addresses the review's B/D/F classes + single-sourcing:

- B (credential corruption): the rfc8693 refresh-leg rotation is now
  persisted the instant it is obtained, BEFORE the exchange leg, via a
  persist_rotation callback under the held credential lock. A rotated RT
  survives an exchange-leg failure (no more cascade lockout), and the
  exchange response's own audience-scoped RT is never written to the
  shared credential.
- D (wrong-audience bearer): the entra leg ALWAYS pins scope=<audience>/
  .default (scope is Entra's only audience carrier); per-server
  oauth_scopes no longer replaces it (that dropped the audience and
  leaked a Graph-audience token to the MCP server). oauth_scopes stays a
  rfc8693-only knob.
- F (state-machine divergence): extracted _handle_refresh_failure, called
  by BOTH oauth_user and oauth_obo — oauth_user behaviour byte-identical
  (1304 tests green). Fixes: obo cooldown now gated on needs-mint so a
  force_refresh 401-retry falls through (2063); credential decrypt errors
  classified not raised (2099); permanent-rejection arms the cooldown as
  a terminal backstop so it stops re-minting + re-auditing every dispatch
  (2156); malformed-200 resets the ambiguous streak (2196); misconfig
  arms the cooldown to dampen the log/SQL flood (2089); server_row
  threaded from the dispatch caller to drop a hot-path SQL round-trip (2069).
- messaging (5993): obo refresh_failed now points at re-login/admin, not a
  nonexistent per-server consent flow.
- single-source (580/9732/217/1830): USER_SCOPED_AUTH_TYPES +
  is_user_scoped_auth live in mcp_crypto (leaf), re-exported; OBO_GRANT_
  PROFILES derives from _OBO_MINT_LEGS and drives oidc validation (was
  dead-exported).

+5 obo regression tests (rotation-survives-exchange-fail, exchange-RT-
ignored, terminal cooldown, cooldown fall-through, decrypt classified).

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley e38e573f7c feat(mcp): route oauth_obo servers through the per-user pool
Gate sweep of the pool-backed class: oauth_obo joins oauth_user at
every pool-keying site, judged individually -

- _obo_server_names sibling registry (reconcile + boot); priming,
  keep-alive sweep, and consent-flow sites deliberately keep iterating
  _oauth_user_server_names only (obo has no per-server consent; its
  keep-alive lands with the credential lifecycle work)
- pool routing/status/static-health/tool-resolve gates use the shared
  is_user_scoped_auth predicate; status reports the real auth_type
- dispatch: _pool_token_lookup routes oauth_obo to the mint engine;
  'missing' detail becomes a re-login message (no per-server Connect
  URL is advertised - _build_consent_url already returns None)
- _db_servers_to_config skips obo rows from static auto-connect (would
  handshake-fail with empty headers and trip the breaker)
- web_search backend refusal covers both per-user auth types
- console: oauth_obo in _MCP_AUTH_TYPES, https enforcement extended;
  startup key requirement counts obo rows (encrypted mint cache)

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley fd60450700 test(mcp): pin oauth_obo mint engine wire shapes and custody semantics
14 cases with exact request-body assertions per the spike-verified
shapes: entra default-scope + per-server override, rfc8693 two-call
chain with subject-token threading and rotation write-back, cache-hit
zero-call fast path, permanent-rejection cache-drop-credential-kept
(with the token_revoked audit row), transient cooldown short-circuit,
and loud-but-retryable misconfiguration.

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley 012ad2a9b3 feat(mcp): oauth_obo mint engine - single-credential per-server token minting
get_obo_access_token_classified: sibling of the oauth_user classified
lookup sharing its result vocabulary, cache table, locks, and backoff,
but 'refresh' = mint from the user's captured credential via the
deployment grant leg ([oidc] obo_grant_profile):

- entra: one refresh-token redemption, scope=<audience>/.default
- rfc8693: refresh grant -> standard token exchange (audience=)

Both wire shapes are spike-verified (docs/design/obo-spike). Key
semantics: a missing cache row mints (no consent prerequisite); a
PERMANENT rejection drops only the per-server cache row - the shared
credential is never auto-deleted, so one mis-granted server cannot
lock a user out of the rest; rotation write-back persists the newest
credential BEFORE the cache write; mints single-flight cluster-wide on
a per-(user, issuer) advisory lock.

is_user_scoped_auth/USER_SCOPED_AUTH_TYPES define the pool-keyed auth
class once for the upcoming client-side gate sweep.

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley 0732f9a7d2 feat(mcp): capture the IdP refresh token at OIDC login (opt-in)
[oidc] capture_user_credential (default off; env
TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL) persists the user's IdP refresh
token - encrypted with the MCP token envelope - as the single
credential oauth_obo servers will redeem on demand.

- enabling the knob appends offline_access to the login scopes
  (idempotent when the operator already lists it)
- capture runs after user provisioning and is best-effort: a capture
  failure logs loudly but never blocks login; the mint path surfaces a
  missing credential on the reconnect rail
- startup hard-fails (SystemExit) when capture is enabled without a
  [security] token encryption key, same as the oauth_user enforcement

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley be22eb2fee feat(mcp): add oidc_user_credentials storage for single-credential minting
One captured IdP refresh token per (user, issuer), Fernet-encrypted with
the same envelope as mcp_user_tokens - the credential that
auth_type='oauth_obo' servers will redeem on demand for per-server
access tokens instead of holding per-(user, server) refresh tokens.

- migration 067 + mirrored create_all schema (parity-tested)
- storage protocol + both backends: upsert (replace-on-conflict),
  get, rotation write-back, delete, delete_user cascade
- MCPTokenStore encrypt/decrypt wrappers

Refs #551.
2026-07-12 19:03:35 -07:00
Patrick Buckley f4e54ce814 fix(install): gate get.docker.com by $ID instead of trapping all failures
Deciding the installer up front — get.docker.com for the IDs it recognizes,
Docker's repo directly for unrecognized derivatives — avoids treating a
transient get.docker.com failure (network, apt lock, EOL sleep) on a supported
distro as an "unsupported distro" and silently routing it into the repo path.

Recognized IDs now surface the real failure via die instead of masking it;
unrecognized derivatives (Nobara, Mint, …) skip the doomed call and its
"Unsupported distribution" output entirely rather than running it to fail.

Addresses review feedback on #829.
2026-07-11 18:41:05 -07:00
Patrick Buckley 4d423913b1 fix(install): install Docker on distros get.docker.com rejects
run.sh delegates Docker installation to get.docker.com, which detects the
distro from $ID alone and aborts with "Unsupported distribution '<id>'" on
any derivative it doesn't hardcode — Nobara (the reported case), Linux Mint,
Pop!_OS, AlmaLinux, Oracle Linux, and so on. run.sh's own detection already
resolves these via ID_LIKE/fallback, so the family is known; only the
delegated install fails.

When get.docker.com exits non-zero, fall back to adding Docker's official CE
repo for the upstream the family maps to and installing the same packages
(including the compose plugin the rest of run.sh depends on). Upstream is
chosen from PLATFORM_ID for the dnf family — Fedora is platform:fNN, Enterprise
Linux platform:elN, which ID_LIKE cannot distinguish (Nobara's is
"rhel centos fedora" yet it is pure Fedora) — and from UBUNTU_CODENAME for the
apt family, which is present only on Ubuntu lineage and is the exact codename
Docker's repo expects (Mint's VERSION_CODENAME is not).

Fixes #822.
2026-07-11 18:41:05 -07:00
Patrick Buckley a4876c00e2 fix(admin): add create-admin CLI; stop run.sh onboarding into a role-less user
The installer's "Finish setup" told users to run `turnstone-admin create-user`,
which creates a user with no role. Web login derives scopes solely from assigned
roles (empty perms -> read only), so that account logs in read-only and every
admin action fails with "Forbidden: token lacks 'approve' scope". Creating any
user also flips setup_required to false, so the browser first-run wizard -- the
only path that assigns the builtin-admin role -- never appears.

- run.sh: point "Finish setup" at the web setup wizard; use create-admin as the
  headless fallback instead of create-user
- admin.py: add `create-admin` -- creates a user + assigns builtin-admin, or
  promotes an existing role-less user (idempotent); guards on the seeded admin
  role and enforces the wizard's 8-char password floor for fresh accounts
- tests: cover fresh-grant (approve reaches the derived login scope), the
  promote/recovery path, idempotency, and both validation exits

Fixes #824
2026-07-11 18:15:48 -07:00
Patrick Buckley 6a94dc1d57 fix(judge): thread model-definition capabilities into judge completions
The intent judge and output-guard judge were the only create_completion
callers that never passed model-definition capabilities, so operator-declared
capabilities (effort passthrough, tool support, temperature, verbosity) were
silently ignored on judge calls. Every in-ChatSession lane threads them via
_resolve_capabilities; the judges live outside the session and never reached
it.

Add a shared _resolve_model_capabilities() helper mirroring
ChatSession._resolve_capabilities, and have both judges resolve
self._capabilities — from the judge alias's model definition, or the injected
session capabilities on the session-model fallback — and pass capabilities=
into create_completion. Replace each judge's context_window int arg with
session_capabilities: the fallback window now derives from the resolved caps
(identical to what the session passed before), while the alias path keeps
reading ModelConfig.context_window, a separate field the capability merge must
not touch.

Refresh the stale docs/judge.md note claiming sub-agents are exempt from intent
validation — task agents have been judge-gated since #773.

Refs #823
2026-07-11 17:53:24 -07:00
Patrick Buckley 8e2657248d docs(task-agent): record the decided durable-sub-turn id strategy at the mint site
If sub-turns ever persist: Turn-IR verbatim, re-mint at load (run_seq is
session-scoped), rebuild the wire map from the native lane's structural
1:1 pairing with the mirror; turns without native client tool blocks
need no entries. The map itself is never persisted — it is derivable,
and a second durable source of truth would have to be kept in lockstep
with the turns. Also documents why the mint must never be string-split
(not injective: parent and original may contain the delimiter).
2026-07-11 16:37:13 -07:00
Patrick Buckley 660aff6f1e fix(task-agent): guard the Google swap against partial lanes; fix a stale comment
The fidelity swap now requires the raw lane to be a faithful counterpart
of the mirror — same length, every id present — before replacing
tool_calls; a partially-corrupted lane (filtered non-dict elements)
would otherwise swap a shorter list over the mirror and orphan a
mirrored call whose tool result remains in history. The _run_agent
call-site comment now matches the builder's reasoning_text-only
blank-id rule.
2026-07-11 16:37:13 -07:00
Patrick Buckley dc52bc2b96 fix(task-agent): simplify the blank-id rule to reasoning_text-only and heal historical rows
The blank-id gate's strip-then-filter semantics left two residual
hazards (surviving Responses reasoning items whose pairing contract
needs their original sibling items; an asymmetric Messages-shaped lane
surviving when no client block was actually stripped). The rule is now
total and simpler: on a blank-id turn only the loose-text
reasoning_text synth block survives — it carries no id and is
shape-invalid on the Messages translator by design, and real-world
blank-id servers are Chat-Completions locals whose reasoning IS that
loose text. This also removes the builder's per-call provider import.

The Google fidelity swap now skips raw rows carrying a blank id
(historical captures that predate the gate would otherwise resurrect
the blank id on every replay — the sanitized mirror stays), guards
against non-dict lane elements, and legalizes via the new shared
lowering.legalize_tool_call_entry — the ONE per-entry legalizer the
sanitize pass also uses, so the two seats cannot drift on semantics or
the wire.tool_args_legalized breadcrumb.
2026-07-11 16:37:13 -07:00
Patrick Buckley 98cefc3660 fix(task-agent): move the blank-id gate into the shared native-lane builder
The blank-provider-id gate lived only at the _run_agent call site while
the main-loop stream accumulator has the identical back-fill-then-carry
seam — and it over-dropped, discarding the reasoning lane for exactly
the servers that emit blank ids. The gate now lives in
_finalize_provider_blocks as a had_blank_ids parameter both harnesses
thread: client tool blocks (which keep the blank id the mirror back-fill
never reached) are stripped, and when any were present the remaining
Messages-shaped blocks go with them (a surviving native lane REPLACES
the rebuilt content on the Anthropic translator, so a lane missing its
tool_use would orphan every mirrored call) — while shape-invalid
reasoning residuals (reasoning_text, Responses reasoning items) are
kept. This also closes the pre-existing main-loop case: a Gemini
openai-compat turn with a blank tool id no longer persists a raw
fidelity dict whose blank id the swap would resurrect on every replay.

The Google fidelity-swap legalization now reuses the canonical
lowering.legalized_arguments (made public) instead of a hand-rolled
narrower copy: dict-shaped arguments are serialized rather than
collapsed to {}, the standard wire.tool_args_legalized breadcrumb is
logged, and a degenerate non-dict function entry passes through
untouched instead of raising.
2026-07-11 16:37:13 -07:00
Patrick Buckley 646bceed52 fix(task-agent): review fixes for the native-lane carry
- Skip the native lane on a turn whose provider left a tool-call id
  blank: the uuid back-fill reaches only the tool_calls mirror, so a
  carried native tool_use block would replay the blank id and desync
  from the restored tool_result (Anthropic orphans the result; Google
  re-fills a fresh uuid). The rebuild path keeps every representation
  on the back-filled id — the pre-native behaviour, for exactly the
  degenerate case.
- Extract _reasoning_text as the ONE Chat-Completions reasoning
  extractor shared by the streaming and non-streaming paths: first
  non-empty STRING of reasoning/reasoning_content wins, so a server
  putting a structured object in reasoning can neither shadow valid
  text in reasoning_content nor leak a non-str into the session's
  reasoning accumulator.
- Legalize arguments when GoogleProvider's fidelity swap replaces the
  sanitized tool_calls mirror with the raw provider dicts — the swap
  could resurrect a malformed arguments string the upstream sanitize
  pass had fixed (pre-existing on the main loop; ids and
  thought_signature untouched).
- Drop the redundant emptiness guard on the agent seam's
  reasoning_parts (the shared finalize helper already guards) and
  document the wire_id_map lifetime invariant for future
  resumable/background agents.
2026-07-11 16:37:13 -07:00
Patrick Buckley d660819142 feat(task-agent): carry the provider-native reasoning lane in the sub-harness
A task agent's replayed turns now carry the native reasoning lane the
model produced (Anthropic thinking blocks + signatures, OpenAI Responses
reasoning items, Gemini thought_signature blocks, vLLM/llama.cpp parsed
reasoning text) instead of being rebuilt from content + tool_calls with
the reasoning dropped — restoring reasoning continuity across the
agent's own multi-turn tool loop on every provider lane.

The prerequisite is the id half: replace legalize_tool_call_ids with
restore_provider_tool_ids, a lowering pass that maps the session-minted
sub-tool ids back to the provider's own ids on the transient wire copy
(from the per-run mint map, never by string-splitting). The native
tool_use block is replayed verbatim — its id and signature untouched —
and the top-level mirror and tool_result agree with it on every request.
The minted id stays the sole internal key (registry, DOM, recall,
cancel ledger), #820 unchanged.

Chat-Completions lane: non-streaming create_completion now surfaces
reasoning/reasoning_content as CompletionResult.reasoning (the twin of
the streaming reasoning_delta extraction), and the agent seam runs the
Phase 5 vLLM reasoning-field replay against the agent's own provider
and alias. The native lane is finalized by a shared helper
(_finalize_provider_blocks) so the main loop and the sub-harness cannot
drift; replay honors the per-model replay_reasoning_to_model flag on
every lane, and llama.cpp stays capture-only, matching the main loop.
2026-07-11 16:37:13 -07:00
Patrick Buckley a5c3dc00fc fix(task-agent): address Copilot review on the id projection
- wire_safe_tool_call_id: SHA-256 not SHA-1 for the deterministic token —
  matches the codebase convention for fingerprints (attachments, auth,
  session) and drops the SHA-1 scanner flag. Non-crypto use, ids unchanged
  in shape (tid_ + 32 hex); no test pins the literal value.
- interactive.js: the two sub-agent child-id example comments now show the
  real minted shape (<parent>::r{run}s{step}::<id>), not a stale <seq> form.
2026-07-11 13:12:27 -07:00
Patrick Buckley 110d6b4fc0 fix(task-agent): mint session-unique sub-tool ids
Sub-agent tool ids were namespaced {parent}::{provider_id} — unique
across concurrent agents but not across turns within one agent. A local
provider reissuing "call_0" every response minted the same id twice, so
the live card's DOM row lookup collapsed distinct calls onto one row
while FIFO recall kept them apart: two views of one trajectory disagreed
on identical input (the bug-3 id-consistency defect). When the provider
also reuses the PARENT call id, sequential runs repeated the collision
one level up.

Mint {parent}::r{run}s{step}::{provider_id} at the single rewrite point:
a session-monotonic run tag (lock-allocated; runs start concurrently on
the 4-wide task pool) plus a per-run step tag make each id unique within
the session, and every consumer — nesting registry, error flags, DOM
data-call-id, recall projection, cancel ledger — keys on that one id.
The FIFO pairing helper stays as honest pairing for un-minted input
(unparented runs, direct construction), with its rationale rewritten.

The agent wire seam (_run_agent's _api_call) also runs the same two
validity passes the main loop already ran — sanitize_tool_call_arguments
(a documented vLLM deepseek_v4 renders malformed args and 400s; agents
hit the same backends) and legalize_tool_call_ids (projects the long,
::-containing ids to plain tokens, call/result pairing preserved). The
id projection is DEFENSIVE hardening, not a fix for an observed break:
the ids replay fine on the lenient anthropic-compatible deployment (the
prior ::-containing format ran reliably), it just keeps an agent's
self-built history valid on a hypothetically stricter backend. Applied
at the agent seam only — main-loop assistant turns carry a provider-
native block lane whose id must stay byte-identical to the mirrored
tool_calls, so the projection cannot run there without desyncing them.

Follow-ups: parent-level card aliasing under a reused parent id; the same
id hygiene for the main conversation loop / native lane.
2026-07-11 13:12:27 -07:00
Patrick Buckley 062a260c88 fix(console): scope response-control dirty flag to the identity that set it
A dirty flag set by touching a verbosity/reasoning-mode select survives a
model/provider/surface change, so the merge-side delete could destroy a key
hand-typed into the Advanced JSON for the renamed row. Honor the dirty
override only while the identity still matches the row that made it dirty.

Also document the captured-value fallback contract at both sites (the
baseline is deliberately not consulted: it arrives async or never on the
compat lane, capture has already lifted the value out of the row JSON, and
emission is gated server-side on the merged supports_* flag) and pin the
fallback plus the scoped dirty-delete in test_app_js.
2026-07-10 15:59:40 -07:00
Patrick Buckley 03861e0cf5 feat(console): verbosity and reasoning-mode controls in the model shelf
- capability-gated "Response controls" on the Models create/edit
  shelf: Output verbosity (low/medium/high) and Reasoning mode
  (Standard/Pro), shown only for Responses-surface models; the empty
  selection means provider default and omits the capability key
- values lift out of the capabilities JSON into the selects on edit
  and merge back on save with identity tracking, so changing the
  provider/model/surface resets them instead of carrying a value
  across models; the Advanced JSON textarea wins unless the select
  was touched last
- known GPT-5.6 models inherit support from the static table without
  persisting redundant support flags; OpenAI-compatible models pinned
  to the Responses surface opt in via the supports_verbosity /
  supports_pro_mode tiles
- invalidate in-flight capability lookups on any identity field
  change and on modal open so a stale response cannot clobber a fresh
  shelf; API-surface changes now run the full field-change path
- model list rows surface verbosity= / mode= override chips
2026-07-10 15:59:40 -07:00
Patrick Buckley b450b9ad20 fix(providers): align GPT-5.6 with the GA API surface
- every 5.6 tier accepts effort "max" and reasoning.mode
  "standard"/"pro" (GA docs: pro is a request mode on any GPT-5.6
  model) -- drop the Sol-only gating
- GPT-5.6 deprecates prompt_cache_retention; send
  prompt_cache_options={"ttl": "30m"} (its only supported lifetime)
  and keep the 24h retention policy for pre-5.6 models
- never inject commercial cache params into local lanes: dropped from
  the Chat Completions lane (which serves only openai-compatible and
  google) and gated off the compat-pinned Responses lane -- a gpt-5*
  served-model name is not an OpenAI account
- account cache writes: usage *_tokens_details.cache_write_tokens
  flows into cache_creation_tokens (5.6 bills writes at 1.25x the
  uncached input rate)
- drop non-string verbosity/reasoning_mode overrides with a warning
  instead of raising on unhashable capability-JSON values
- keep ModelCapabilities' public positional prefix stable by appending
  the verbosity/pro fields at the tail; pin it with a constructor test
- openai floor 2.44 -> 2.45, the first release with the typed
  prompt_cache_options kwarg
2026-07-10 15:59:40 -07:00
Patrick Buckley dc647f4d63 fix(bash): report exit code for killed shells; single import style in registry tests
Copilot: bash_output's schema promises the exit code once the shell has
exited, but the formatting attached it only to 'completed' — a killed
shell has one too (the negated signal number). Attach it to any exited
state.

Code-quality: the registry test file mixed a top-level from-import with
function-local 'import ... as bg_mod' for monkeypatching module
attributes; one from-style module alias at the top now serves all of
them.
2026-07-10 15:42:33 -07:00
Patrick Buckley ab7d56e0ba feat(bash): opt-in background shells with delta output reader and kill tool (#817)
Restore 'start a dev server, use it in a later call' as an explicit opt-in
after #816 made bash reap its whole process group on return. The surface
mirrors the dominant coding-agent convention: bash(run_in_background=true)
returns a bash_N handle immediately; bash_output(id, filter?) returns only
output produced since the previous read plus status and exit code;
kill_shell(id) terminates the shell's whole process group.

- Per-session BackgroundShellRegistry: capped rolling line buffer with
  drop-oldest gap accounting, exit-order record pruning, owner scoping for
  task_agents (shells reaped when the agent finishes), liveness-guarded
  group kills (a stale pgid is never signalled), budgeted teardown joins.
- Exit notices ride a shared external-event rail (sanitize, soft cap,
  channel 'any', idle wake) now common to watch fires; a new 'quiet'
  NudgeQueue channel lets a user cancel defer pending notices without
  letting them re-wake the stopped workstream, and failed wake delivery
  re-queues external notices seq- and predicate-intact without re-arming
  the wake gate.
- The bash_output filter runs in a killable subprocess: sre holds the GIL
  for an entire search, so no in-process timeout can bound a hostile
  pattern. Scrubbed child env, pinned UTF-8 pipes, honest timeout-vs-
  helper-failure error taxonomy, per-line match window with explicit
  clipping notes; a failed filter never consumes the delta.
- run_in_background rides the bash intent-judge projection; bash_output is
  exempt from the repeat warning but still recorded so interleaved polls
  keep breaking other tools' streaks; all bash boolean args share one
  lenient coercion dialect.
- Shells survive generation cancel and die with the workstream: every
  teardown path funnels through ChatSession.close(); CLI exit and the
  server lifespan now close every loaded session, signal-first and
  Ctrl-C-safe, so nothing detached outlives a graceful shutdown.
2026-07-10 15:42:33 -07:00
Patrick Buckley bec757a96b test(bash): use tmp_path fixture instead of tempfile.mktemp
CodeQL flagged tempfile.mktemp as an insecure temporary file and Copilot flagged the same call as race-prone (the path is not reserved). Use the pytest tmp_path fixture, which reserves a unique per-test directory and is cleaned up automatically.
2026-07-10 00:04:40 -07:00
Patrick Buckley c1cfb668b9 docs(changelog): sync 1.7.x release notes from stable/1.7; note bash fix
main was missing the 1.7.1 through 1.7.3 sections and the two-track preamble that shipped on stable/1.7; bring them in and add an Unreleased entry for the bash background-hang fix.
2026-07-10 00:04:40 -07:00
Patrick Buckley f1f488aa55 fix(bash): do not hang when a command backgrounds a long-lived process
A bash command that leaves a process running in the background (server &, a daemon) could wedge the whole workstream forever: the tool read stdout/stderr to EOF, which never arrives because the child inherits the pipe, and the timeout watchdog bailed the moment the tracked bash exited.

Wait on the tracked process bounded by the tool timeout (keyed on process exit, not pipe EOF) and terminate its whole session group on every exit path, reaping any backgrounded survivor, forcing the drain threads to EOF, and leaving nothing to leak. Decode with errors=replace so undecodable output is preserved instead of dropped, and pre-bind proc so a Popen failure surfaces the real error.

Behavior change: a process the command backgrounds no longer survives the call. First-class opt-in backgrounding is left as a separate change.
2026-07-10 00:04:40 -07:00
Patrick Buckley 9668862a7f fix(personas): engineer prompt wording from PR feedback
Name the task_agent tool literally so the model connects the guidance
to the tool the persona grants, and restore "asking for permission".
2026-07-09 19:19:02 -07:00
Patrick Buckley a0d7e2266e feat(personas): harden engineer base prompt with process discipline
engineer.md is the default BASE module for non-coordinator sessions.
Rework it from posture-level guidance to explicit process discipline:
phased work (understand, design, plan, edit, verify) with ceremony
scaled to the size of the change, red-green as the default for
testable work, minimal-diff scoping, a thrash-stop after repeated
failed attempts, and reporting only observed results. Exploration
delegates to task agents; push-back happens once, then defers with
the disagreement stated for the record.
2026-07-09 19:19:02 -07:00
Patrick Buckley 2ca4113ce5 docs(hypothesis): carry the factored Q_E reading into the glossary; primer wording
Review follow-ups: the s-shorthand convention and its glossary echo now
cover Q_E's own state argument (s -> w where Q_E reads it), and the Q_E
glossary row carries the factored (w, a) ~> (w', o) reading so the
symbol table no longer reintroduces the environment-reads-all-of-s
interpretation the outer-kernel note warns against. PRIMER: the
top-alone-widens bullet keeps owner language anchored to the
simple-case top; success is defined as an accepted end, consistent
with the declared-vs-actually-right distinction two sentences later.
2026-07-09 19:14:32 -07:00
Patrick Buckley 31301ba2a6 docs(hypothesis): harden the normal form; sync PRIMER
HYPOTHESIS.md:
- carry the initial law mu_0 in the tuple (and its displayed signature);
  split the rejection symbol into parse failure vs authorization
  refusal, with gamma(s, bot_Y) = bot_A as an axiom and a positional
  convention for the remaining bare bots
- factor the state s = (q, w) and retype Q_E to (w, a) ~> (w', o) so the
  latent world has a generator and the displayed T is its stated
  projection; quantify fail-closed over a rejection-invariant safe set K
- read H_ok as operational acceptance (H_acc) against analysis-only
  success G, with a convention for which claims read which side; score
  C6's ceiling against G and pin C5's slack to the correct-halting
  drift, resolving the tension with its own falsifier
- state ledger integrity relative to an attestation assumption (reported
  vs actual effects); split cancellation into safe vs unresolved and
  count unresolved as possibly-bad; add the realizability clause to
  C1/C3; admit multi-principal trust tops as deployment choices
- reversibility is declared in the tool contract the gate reads at
  authorization; the returned record's mark is confirmation, not source

PRIMER.md: mirror the same corrections in plain language -- ceiling not
cliff for the desk wall, contract-first reversibility, the multi-party
trust top (including the summary line), declared-vs-actual success on
dashboards, reported-vs-actual ledger honesty, cancel is not
automatically safe.
2026-07-09 19:14:32 -07:00
Patrick Buckley f5f721a979 fix(providers): include allowed reasoning modes in the unknown-mode warning
Mirror the verbosity warning so an operator typo in reasoning_mode logs the allowed values, not just the offending one.
2026-07-09 18:48:51 -07:00
Patrick Buckley 47f908c9e0 feat(providers): add OpenAI GPT-5.6 (Sol/Terra/Luna) support
Onboard the GPT-5.6 family (GA 2026-07-09) to the OpenAI Responses lane.

- Capability rows for gpt-5.6 (= Sol alias/catch-all), gpt-5.6-terra, and
  gpt-5.6-luna: 1.05M context, 128K output, tool_search/vision/pdf/reasoning
  replay, default effort medium, temperature only at effort=none.
- "max" reasoning effort, Sol-only; Terra/Luna cap at xhigh (the knob's "max"
  snaps to the xhigh ceiling). First commercial OpenAI use of "max" — the
  ordinal knob already ranked it, so no effort-ladder change was needed.
- Verbosity and pro mode as operator-declared capability fields
  (supports_verbosity/verbosity, supports_pro_mode/reasoning_mode), merged
  from the model-definition capabilities JSON and emitted on the Responses
  wire as text.verbosity and reasoning.mode. Both are gated by a supports
  flag plus an enum guard that drops unknown values with a warning. Pro mode
  is Sol-only. There is no gpt-5.6-pro model — "pro" is the reasoning.mode
  param, not a separate model id.
- Raise the openai floor to >=2.44 for the 5.6 Responses params.

Unit and wire-golden tests cover the rows, max->xhigh snapping, the two
levers, and the enum guards. Validated live against the OpenAI API: gpt-5.6
accepts the model id, effort "max", text.verbosity, and reasoning.mode="pro".
2026-07-09 18:48:51 -07:00
Patrick Buckley 2a32211e4a feat(webui): port SSE overflow-recovery companions to the coordinator pane
The #805 server-side fixes (emit-time batching, _ListenerQueue poison,
out-of-band closing) already cover every SSE stream, but the client-side
companions lived only in the interactive pane. Port them to coordinator.js
and extract the drift-prone pure core into a shared module (closes #806).

- shared_static/sse_overflow.js (new): storm-guard constants +
  overflowWindowTripped + degradedCooldownStep, imported by both panes so the
  trip threshold and cooldown ladder have one source of truth. interactive.js
  imports these instead of holding local copies; the two node runtime probes
  move to tests/test_sse_overflow_js.py.
- coordinator.js: handle the stream_overflow frame (storm guard -> degraded
  catch-up with a doubling cooldown; the reconnect replays from the ring, or
  falls to the replay_truncated -> /history floor); add the close-on-hide /
  replay-on-show visibilitychange handler plus a document.hidden guard at the
  connectSSE chokepoint; add drop-vs-render-wedge counters (onmessage now wraps
  the dispatch in try/catch -- the coordinator previously had no wedge guard,
  so a handler throw silently poisoned every later turn).
- After a stream gap the children/tasks sidebar re-syncs only when the ring
  replay cannot cover it: no resume cursor, a replay_truncated envelope, a gap
  beyond the cursor-trust window, or a live event id below the saved cursor (a
  process restart reset the counter, which the replay path reports as a false
  replay_ok). child_ws_*/task events are ordinary ring entries, so an ordinary
  short reconnect heals the sidebar through the live handlers with no REST
  rebuild -- a momentary blur/focus under close-on-hide rebuilds nothing.
- Close-session teardown detaches the visibility handler before the close POST
  so a hide/show mid-close can't resurrect a dying stream. A replay_truncated
  seen mid-stream is deferred (not dropped) and re-synced from /history on the
  next idle -- repairing both a ring-evicted gap and a turn stranded by
  close-on-hide (stream_end evicted while hidden), matching interactive.js's
  _pendingTruncatedResync.

The extraction stops at the pure core: interactive.js's stateful glue is
hard-pinned by its source-assertion suite, so its class-method shape stays put
and the coordinator reimplements the equivalent glue as closure functions.

Tests: new test_sse_overflow_js.py (module exports + the two runtime probes);
coordinator parity + lifecycle pins in test_app_js.py (replay-aware sidebar
refresh, restart detection, truncated-resync deferral, close-session
visibility detach); interactive's moved probes replaced by an extraction pin.
All JS-source suites green.
2026-07-08 16:58:23 -07:00
Patrick Buckley d115111756 docs(hypothesis): daemons + the outer loop; plain-language PRIMER
HYPOTHESIS.md:
- New appendix entry "Daemons (the recurrent harness)": a daemon as the
  regenerative process of concatenated runs — ready-set recurrence,
  renewal-reward lifting exactly at regeneration points, accumulation as
  what breaks regeneration (cross-cycle provenance meet, renewal events
  that reset accumulated risk), and authority under intermittence
  (owner contact as a renewal point for authority; TOCTOU at cycle
  scale).
- New body section "The loop": the task-dispatching outer loop as the
  harness construction applied one level out — the composition
  correspondence read at the top level, the daemon as its single-agent
  special case, the bare while-loop as the trivial-group harness one
  level up. Flagged as a sketch; outer fail-closed/reach-avoid
  treatment deferred to later rounds.
- Veto caveat threaded to match: judge-as-veto safety scoped to the
  authority lattice, and the nonblocking escape degrades to an
  always-enabled safe halt when the principal is unreachable.
- Consistency: Grounding's Asserted tier now covers "The loop";
  "always-enabled escalation" -> "escape" (the appendix's own term, now
  that the escape has an unattended form); brace the one unbraced \bot
  subscript (linter section-B HIT).

PRIMER.md: new plain-language companion — same object, no symbols, the
formal doc wins every disagreement. README's entry link now points at
the primer, which links onward to HYPOTHESIS.md.
2026-07-08 02:49:46 -07:00
Patrick Buckley 5dcf66c284 fix(webui): share renderer-output CSS so the console + coordinator highlight code
highlight.js, KaTeX and Mermaid all run on every surface via the shared
renderer (renderer.js), but their theme/wrapper CSS lived only in
ui/static/style.css. The console and coordinator load /static/style.css from
console/static/ — a different file on a different server — so hljs token spans
fell back to --fg (flat monospace for several releases), and the KaTeX/Mermaid
wrappers lacked their overflow containers, letting wide equations/diagrams
overflow the pane.

Move the hljs theme, .katex-display/.katex-error and the .mermaid-* wrappers
into shared_static/chat.css, which every surface loads via /shared/chat.css.
Restate the mermaid width-clamp for the preview pane (.preview-markdown) too,
since its content isn't a .msg.assistant message.

Drop the redundant background on .msg.assistant pre code.hljs so the <pre>
carries the code surface on every surface — otherwise the console/coordinator
(where the pre is --panel, not --code-bg) showed a darker box inside a lighter
padding band.
2026-07-08 01:37:46 -07:00
Patrick Buckley c328bebecd feat(schedules): add persona and project settings to scheduled tasks
A scheduled task could pin the model and skill of the workstream each
firing creates; it can now also pin its persona and project, so a
schedule can run under, e.g., the researcher persona attached to a
specific project's memory bucket.

The two values live on scheduled_tasks (migration 066, Text NOT NULL
default '') and are passed verbatim to create_workstream at dispatch,
where the node resolves the persona for the workstream kind and gates
the project attach. Empty means "kind-default persona / no project",
resolved late at each firing (mirrors how empty model/skill already
behave) -- existing schedules keep byte-identical dispatch behaviour,
so there is no backfill.

Also fixes a latent bug this feature depends on: admin_create_schedule
read created_by from request.state.user_id, which AuthMiddleware never
sets, so every scheduled task stored created_by=''. It now reads
auth_result.user_id like every other console endpoint. This is now
load-bearing -- the scheduler dispatches under created_by and the node
gates the project attach against it. admin_update_schedule adopts the
editing admin as owner when a project is assigned to a pre-fix orphaned
('') schedule, and re-validates persona/project only when they change
so a since-disabled persona or lost membership does not block unrelated
edits (the node re-checks at dispatch either way).

Wired through: schema + migration (up/down + parity tested), both
storage backends, API schemas, SDK create_workstream and console
create_schedule/update_schedule, scheduler dispatch, and the admin
schedule shelf (persona + project pickers, current value preserved so
an edit cannot silently clear a filtered-out selection).
2026-07-08 00:59:14 -07:00
Patrick Buckley d5ddc95e9f fix(web_fetch): inherit model settings for the extraction completion
The URL-extraction call hard-coded max_tokens=8192 and rode the "low"
reasoning default, which broke local-inference models whose registry entry
advertises a tighter output limit or a different reasoning config. Inherit
the session/registry max_tokens and reasoning_effort instead (temperature
already was) — the same knobs the main turn uses.

max_tokens is capped to context_window // 4, the ~25% output slice Phase 2
already reserves, matching the main turn's response reserve
(_remaining_token_budget), so a large operator budget can't push
prompt + output past a small context window on strict runtimes.
2026-07-08 00:30:44 -07:00
Patrick Buckley 026c646116 test(sse): normalize session_ui_base imports to a single style
github-code-quality flagged 8 spots where tests imported
turnstone.core.session_ui_base both as `from ... import` and `import ... as
suib` (the alias was only there to monkeypatch the module-level batch
constants). Drop the alias and patch via string target
(`monkeypatch.setattr("turnstone.core.session_ui_base._TOKEN_BATCH_WINDOW_SECS", ...)`),
which resolves to the same module global — behavior-identical. The one test
that READS the constant imports the symbol directly. Test-only, no
production change.
2026-07-07 23:32:20 -07:00
Patrick Buckley dfe09d029b fix(sse): guard connectSSE against opening into a hidden tab; fix stale closing comment
PR #805 review (Copilot + the round-3 finding it corroborates):

- connectSSE opened a new EventSource even when the tab was already hidden
  (e.g. a first load in a background tab), where the close-on-hide handler
  never fires because there is no open stream to close — so a throttled
  hidden tab could still become the slow consumer this PR prevents. Add the
  document.hidden guard at the single connect chokepoint, after the wsId
  assignment + visibilitychange-handler install (so the show edge reconnects)
  and before new EventSource (so nothing opens). The timer callbacks keep
  their own pre-checks (the recover beat's also gates failCount); this closes
  the fresh-connect path they never covered.

- Fix the stale _ListenerQueue.closing docstring: it claimed the drain loop
  checks closing BEFORE poisoned, but the round-2 fix moved that check INSIDE
  the poison branch (poisoned+closing -> clean close; a healthy closing queue
  drains its tail to the ws_closed sentinel). Wording now matches the code.
2026-07-07 23:32:20 -07:00
Patrick Buckley 5083f67e96 fix(sse): batch fast-stream tokens and recover overflowed listeners
A long live session driven by a fast local model (500-2000 tok/s) showed
corrupted / missing spans of assistant text while the backend stayed
healthy. Root cause: on_content_token/on_reasoning_token enqueued one SSE
event per model delta, so the per-listener queue (cap 500) overflowed
against any slow consumer; put_nowait on a full queue silently dropped the
newest event. Once saturated, drops scatter (the consumer keeps freeing
single slots), so the client's lastEventId sails past the holes and
reconnect-replay (eid > last_event_id) can never heal them. A dropped
fence-closer reshapes all downstream markdown -> reads as heavy corruption.

Fix B (primary) - emit-time micro-batching:
Coalesce content/reasoning fragments over a ~25 ms window (or 4 KB) into
one _enqueue, cutting the wire event rate ~10-20x at local-inference
speeds. A batch is assembled before it gets an _event_id, so it is one
ordinary ring entry no cursor can fall inside (unlike the forbidden
in-ring coalesce). Two conditions are load-bearing and pinned:
  1. The inflight-buffer append and the enqueue are one _ws_lock section,
     so a snapshot's snap_seq stays a true high-water mark for its text.
     Splitting them lets a straddling snapshot double-render (the client
     content path is a blind +=, no dedup).
  2. Every non-token emit flushes the pending batch first, enforced at the
     single _enqueue choke point, so stream_end/tool_*/state_change can't
     overtake trailing content and repaint it into a new bubble.

Fix A (recovery net) - poison-at-first-overflow:
_ListenerQueue latches `poisoned` atomically at the FIRST rejected put and
refuses every later put, freezing its contents as a contiguous prefix; the
drain loop closes the stream after an id-less stream_overflow frame and the
native EventSource reconnect replays the whole gap from the ring buffer.
Poisoning at the first full (not after N) is required: any deliver-while-
dropping window advances lastEventId past interior holes that reconnect
can't replay. A ws teardown that races the overflow sets an out-of-band
`closing` flag (mark_closing), checked inside the drain loop's poison
branch: a poisoned+closing queue returns clean (no false overflow frame),
while a healthy closing queue still drains its full tail FIFO to the in-band
ws_closed sentinel -- so a slow-but-unpoisoned client never loses the turn's
final content batch + stream_end at teardown.

Client (interactive.js):
- Reconnect storm guard: after 3 overflow closes in 60 s the pane drops to
  a degraded catch-up (stop live streaming, "connection is slow" state,
  reconnect after a doubling 15->120 s cooldown that resyncs from the ring
  or the uncapped /history floor). The cooldown ladder is keyed off a
  last-trip timestamp, not the overflow-window array (which the trip
  clears), so the escalation survives its own backoff.
- Close-on-hide / replay-on-show: a visibilitychange handler closes the
  EventSource on tab-hide (a throttled hidden tab is the likeliest slow
  consumer) and reconnects with the saved Last-Event-ID on show. The
  factory recovery beat defers when hidden, and giveUp() detaches the
  handler, so a dead or backgrounded controller can't reopen a stream.
- Drop-vs-render-wedge counters distinguish this bug (server overflow
  closes) from the handler-wedge class (render/finalize throws) in the
  field. No global gap-detector: live ids are not strictly monotonic
  across concurrent tool+content emit, so a naive id!=last+1 check would
  false-positive; recovery is server-signalled instead.

Corrects the stale _resolve_event_buffer_max comment that justified the
50k ring on a "PR-G closes connections on hide" mitigation that never
existed (the close-on-hide handler above is the real one).

Negative-tested (revert the guarantee, confirm the pin fails, restore):
per-token inflight append -> snapshot straddle double-render; removed
choke-point flush -> stream_end split; no poison latch -> silent drops;
top-of-loop closing check -> healthy-close tail loss; missing mark_closing
wiring / drain closing check -> clean close mis-reported as overflow;
_noteStreamOverflow cooldown reset -> ladder never escalates; removed
hidden-tab recovery guard / giveUp handler removal -> hidden-tab reconnect.
2026-07-07 23:32:20 -07:00
Patrick Buckley e5e48a788a fix(renderer): drop the indent an indented fence close drags into code content
Copilot review on PR #804:
- An indented closing fence line ("  ```") left its leading spaces as a
  trailing whitespace-only line inside the rendered code block: the content
  capture runs up to the backtick run and the close-line indent precedes it, so
  it was captured as content. Strip a trailing newline PLUS any trailing indent
  (/\n[ \t]*$/ instead of /\n$/); a column-0 close is unaffected. Red-green
  pinned (content is exactly "  x = 1", no trailing whitespace line).
- Correct a stale test docstring claiming the fence open anchor allows "up to 3
  spaces" of indent — it allows arbitrary indent (the 4-space case is pinned
  separately).
2026-07-07 23:03:47 -07:00
Patrick Buckley a164d61552 fix(renderer): contain markdown sentinel-forgery and recursive-frame content loss
The markdown renderer protects structural blocks with in-band NUL-framed
sentinels (chr(0)+tag+index+chr(0)). escapeHtml preserves U+0000, so
model/tool text could forge sentinels, and recursively-rendered <details>
bodies re-rendered against fresh block arrays and lost their content. This
lands the ordered containment fixes from the render-containment brief.

Fixes (each pinned in tests/test_renderer_js.py; all NUL-sensitive cases also
confirmed in real headless Chrome, which drops a U+0000 token the node harness
preserves):

- B1/B2/B3 — forged sentinels: strip U+0000 at the TOP-LEVEL render entry only
  (_fnDepth === 0). renderer.js is the sole NUL producer and every restore
  regex is NUL-framed, so removing NUL closes every forgery path (block
  duplication/relocation, out-of-range "undefined", cross-container injection)
  while generated sentinels in recursive frames survive. Only NUL is stripped,
  so a code fence still shows pasted control bytes (ESC/FF/VT/DEL) verbatim.
- B4 — blockquote-in-fence (the common one): the code-fence pass now runs
  before the line-based blockquote pass. Its open matches at line start after
  optional indent and an optional list marker (`- `, `1. `), and re-emits that
  indent+marker before the sentinel so the fence keeps its document position
  (a nested-list item stays nested; a fence continuing a footnote definition
  keeps the indent its continuation scan needs). A blockquoted fence (`> ```)
  is not matched (`>` is neither indent nor a list marker), so the blockquote
  pass extracts that `> ` run and its recursion renders the fence. A `> ` line
  inside a plain fence stays literal.
- B5 — <details> open anchored to line start (^[ \t]*), so a `<details>`
  mentioned mid-line inside inline code no longer starts a block.
- NEW-1 — recursive-frame content loss: <details> extraction runs AFTER fence
  protection and restores a fenced body from a saved raw-source array
  (codeBlockRaw) back to raw markdown before the recursive render, so
  code-in-details renders in-frame instead of restoring to "undefined". Running
  after fence also means a </details> shown as example code inside a fence
  can't close the block early, and a <details> shown inside a fence stays
  literal — no offset-based fence-awareness needed. Inline-code/math in footnote
  definitions render via the restore round-trip the undefined-guard enables
  (documented at the append site).
- NEW-3 — code blocks gained the <p>SENTINEL</p> unwrap variant DT/BQ/MB/TB
  already had, removing a stray empty <p> before a standalone <pre>. The CB
  unwrap is whitespace-tolerant so an indented own-line fence (whose indent the
  fence pass re-emits) also doesn't leave a stray <p>.
- Defense-in-depth: every restore callback returns the matched sentinel
  (inert; the browser drops the NUL) instead of the array's `undefined`.

Non-obvious decisions:
- Control chars are authored as literal \xNN hex escapes (byte-verified: only
  \uXXXX decodes to raw bytes in this toolchain; \xNN matches the file's
  existing \x00 sentinel convention).
- Open anchors allow arbitrary leading indent (the fence open also allows a
  list marker), not CommonMark's ^ {0,3}: the renderer has no indented-code
  fallback, so preserving the prior behaviour of matching indented/list-nested
  fences beats CommonMark strictness, while still excluding `> ``` and mid-line
  forms.
- codeBlockRaw (the <details> raw-fence array) and the restore callbacks are
  factored through a _restorer(arr) helper; codeBlockRaw is only populated when
  the text contains a <details> tag (its sole reader).
- The entry strip is depth-0-only on purpose: an unconditional strip would
  shred the generated sentinels recursive frames carry, foreclosing NEW-1.

Negative-tested (reverted the production line, confirmed the pin fails):
- fence anchor: unanchored swallows a blockquoted fence.
- NEW-1 codeBlockRaw restore: without it, code inside <details> is lost.

Deferred (called out per the brief):
- B6/NEW-4 bidi controls (U+202A–202E, U+2066–2069, U+200E/F) still pass
  through unescaped; they are not C0 so the entry strip misses them. Left to a
  follow-up — stripping risks corrupting legitimate RTL text and <bdi>
  isolation is involved for a string renderer.
2026-07-07 23:03:47 -07:00
Patrick Buckley 2c5adb7aca fix(web): sanitize the latin1_safe_filename fallback too
Review follow-up: the helper returned `fallback` verbatim when the name
sanitized to empty, so a future caller passing an unsafe fallback (non-latin-1,
control chars, quote, backslash) could reintroduce the header crash/corruption
the helper exists to prevent. Not reachable today — all call sites pass safe
ASCII literals — but the helper is a shared safety primitive whose contract is
wire-safe output.

Run the fallback through the same cleaning, backed by a safe constant if even
that is empty, so the return is always wire-safe and never filename="". Adds a
test.
2026-07-07 22:47:15 -07:00
Patrick Buckley fd3aed1eca fix(web): make Content-Disposition filenames safe on the wire
Attachment `/content`, preview, and workstream-export downloads built the
Content-Disposition `filename="..."` value straight from a user-supplied
name, stripping only quotes and CR/LF. Three input classes still broke the
header:

- Non-latin-1 names (CJK, em dash): Starlette encodes header values as
  latin-1 and raised, 500-ing the serving route. (The original get_content
  bug.)
- ASCII control bytes (NUL, form-feed, VT, DEL): latin-1-encodable, so they
  passed Starlette, but the HTTP server layer rejects control characters in a
  header value and 500s one layer later.
- Backslash: the RFC 6266 quoted-pair escape. A trailing backslash escaped
  the closing quote and corrupted the download filename (not a 500, but wrong
  output; Windows-origin uploads carry it legitimately).

Extract one `latin1_safe_filename()` helper in web_helpers that drops every
non-printable character plus the double-quote and backslash quoted-string
metacharacters, folds any surviving non-latin-1 codepoint to '?', and falls
back to a non-empty name so the header never emits an empty filename. Route
get_content, preview_response_headers, and the export handler through it,
replacing three near-duplicate inline strips.

Adds unit tests for the helper (non-latin-1 fold, control-char and backslash
stripping, per-site fallback) and an endpoint regression test.
2026-07-07 22:47:15 -07:00
Patrick Buckley f56fa55929 fix(ui): unsplit skips redundant refresh after closing an ephemeral pane
unsplit() closed each doomed (ephemeral) pane via close() — which already
renders/persists/notifies — then repeated that trio, firing intermediate
persist/notify passes mid-operation. A 2-cell split fully collapses inside
close(), so bail there; only a 3+-cell split (or an empty doom list) still
needs the trailing exit + refresh. The all-conversation path is unchanged.

Also reword the cell-chip CSS comment so it names the reversible hide vs
destructive close glyphs, now that an ephemeral pane can show the close glyph
in split mode.
2026-07-07 22:07:20 -07:00
Patrick Buckley ace9e034f9 fix(ui): ephemeral panes close on split-dismiss instead of orphaning a tab
The preview pane opens beside the conversation as a split cell. Dismissing
that cell — the per-cell chip, or Unsplit from the other pane — ran
closeCell(), which hides the pane but keeps it in _panes/_order, leaving an
orphan tab with no meaningful reopen (the reopen affordance is the transcript
chip, not the tab bar).

Add an `ephemeral` flag on ShellPane. For an ephemeral pane the cell chip and
Unsplit route to close() — destroying the pane and its tab — and the chip's
glyph/label read as a destructive close rather than a reversible hide. Unsplit
still spares the focused survivor even when it is ephemeral ("keep the focused
pane"). The preview pane sets the flag; conversational panes do not, so an
all-conversation split is unchanged (Unsplit reduces to the prior
_exitLayout(_activeId)).
2026-07-07 22:07:20 -07:00
Patrick Buckley cb59afe443 fix(nudge): log refused wakes; correct the already-dispatched hold-clear comment
The wake gate documented exactly one info line per call past its
gates, but a send() refusal (the authoritative under-lock _closed
re-check catching a teardown the gate's lockless peek missed) emitted
nothing — a dropped wake should stay traceable to its trigger, so the
refusal now logs nudge_wake.refused.

The already-dispatched branch's comment claimed a held reminder can
coexist with the terminal mark via a redelivery whose commit raised —
impossible with the current control flow (_redeliver_pending clears
the hold before committing).  Reworded to what the clear actually is:
the last line of defense against any coexisting hold leaking forever
once this branch deactivates the row, since inactive rows never
re-list.  Test comment updated to match.
2026-07-07 16:42:36 -07:00
Patrick Buckley 7886d3b763 fix(nudge): wake gate requires a real NudgeQueue
A session whose _nudge_queue answers has_pending truthily while its
deliver_wake_nudge_from_queue consumes nothing turns the worker-exit
backstop into an infinite respawn loop: the gate passes, the wake
worker no-ops, the exit backstop re-runs the gate, forever.
Mock-backed test sessions riding real Workstreams are exactly that
shape, and one worker on such a pairing is enough to ignite a
wake-thread storm that trips the leaked-thread guard in every
subsequent test.  The wake contract requires real drain semantics —
the spawned worker must CONSUME what the gate saw — so the gate now
refuses on type, not just presence.
2026-07-07 16:42:36 -07:00
Patrick Buckley fa1ba2cc01 fix(api): type initial_message_status as a Literal enum
str | None under-specified the field: the implementation and the TS SDK
union both constrain it to queue_full / refused_closed, and the Literal
projects a proper enum into the generated OpenAPI spec so clients
reject unexpected values. Specs regenerated.
2026-07-07 16:42:36 -07:00
Patrick Buckley e60c19befd fix(watch): harden nudge/wake delivery across eviction, cancel, and identity rebinds
Wake path:
- Denial metacog nudge moves to the tool channel so it drains with the
  denied tool batch instead of the next user-message seam.
- wake_workstream_if_pending: shared wake gate for watch fires on
  already-idle workstreams (no IDLE transition for the watcher to
  observe), wired as wake_fn at every set_watch_runner site via the
  shared _watch_fire_wake_fn helper (closes over the Workstream OBJECT
  — after eviction+restore an id-keyed manager lookup would miss).
- session_worker exit backstop re-runs the wake gate the moment worker
  ownership clears: IDLE fans out on the worker thread, so
  transition-time wakes always landed on the reuse path and no-op'd
  (the coordinator idle_children strand).
- deliver_wake_nudge_from_queue contains GenerationCancelled — it is
  the wake worker's run() closure and only Exception is caught
  downstream.

Watch delivery:
- Terminal fires that cannot reach their workstream are HELD and
  redelivered on min(interval, 60s) without re-running the command,
  bounded by MAX_DELIVERY_ATTEMPTS per cycle and the watch's own
  max_polls across cycles; the poll charge commits durably at hold
  time so restarts stay budget-bounded.
- Restore admission control: per-ws dedup + MAX_CONCURRENT_RESTORES
  cap, presence-only re-check under the lock, detection-only stall
  alerts (reclaiming a wedged admission would trade capped degradation
  for total poll-pool collapse).
- Permanent-vs-transient restore taxonomy: corrupt persona stamp and
  genuinely-missing history (confirmed by a raising storage probe —
  the resume loader swallows read blips into []) deactivate the watch
  immediately; everything else holds and retries.
- Cancel-race defense: delivery paths re-check is_watch_active before
  stashing/dispatching, cancel paths write the row BEFORE
  forget_terminal_dispatched, the HTTP cancel endpoint clears runner
  state, and a per-tick sweep bounds the residual stash-after-clear
  interleaving to one check_interval.
- Abandon/exhaustion commits are write-then-clear so storage that can
  read but not write retries the row write instead of re-running the
  command every cycle; the fresh-fire unrestorable path stashes before
  its deactivation write for the same reason.

Registry follows identity:
- The dispatch registry is keyed by _ws_id at registration time; every
  rebind now moves it: non-fork resume() and /new go through
  _follow_watch_registration (new key live before the old is removed,
  never stealing a registration another live session holds), removals
  are owner-checked so tearing down a watch-restore shell or a
  resumed-away session cannot unregister a live pane, the restore
  shell yields to a registration that appears mid-restore, CLI
  --resume registers after the successful resume, and both the open
  path and the detail-GET lazy rehydrate wire the registration.

Teardown gating and backpressure honesty:
- cleanup_session_ui marks ws._closed FIRST under ws._lock — every
  teardown path (close, close_idle, evict, delete, discard) funnels
  through it — and session_worker.send re-checks under the same lock,
  so a wake can never spawn a worker on a torn-down workstream.
- Create responses carry initial_message_status when the initial
  message could not be delivered (queue_full / refused_closed) instead
  of reading as success; staged attachments survive for the retry;
  /send surfaces a closed workstream as 404 rather than queue_full.

Docs/spec: OpenAPI artifacts regenerated; api-reference documents the
new create-response field; TS SDK type extended.

Tests: ~30 new pins (cancel races, budget durability across restarts,
owner-checked registry moves, teardown gating, stall alerts,
backpressure surfaces, wait_until final re-check); wide subsystem
sweep green (2353 passed).
2026-07-07 16:42:36 -07:00
Patrick Buckley bbe92faca1 fix(preview): fetch ceiling tracks the widest kind cap, not a flat 10 MB
Review feedback (PR #800): the URL lane hard-capped fetched bodies at
10 MB before kind resolution, making the 32 MiB pdf cap unreachable for
URL targets while path targets honored it. The flat pre-check is gone;
the guarded fetch's max_bytes now tracks max(PREVIEW_SIZE_CAPS.values())
- mirroring the path lane's stat pre-check - and the per-kind caps after
resolution stay authoritative.

Also drops a redundant function-local asyncio import in test_console.py.
2026-07-07 08:20:57 -07:00
Patrick Buckley 29a4bbf876 fix(preview,web): stream guarded fetches under a byte budget; salt preview blob ids
fetch_with_ssrf_guard now streams the response under a max_bytes budget
(default 32 MiB, counted on decoded bytes so gzip cannot expand past it)
instead of buffering blind - an unbounded body previously filled memory
before any caller-side size cap could run. Redirect-hop bodies are no
longer read at all, and the realized response drops stale wire-framing
headers (content-encoding/content-length/transfer-encoding) that no
longer describe the decoded content it carries.

Preview blob ids are salted out of the model-visible attachment
namespace (sha256("preview:" + body)): uploads use bare sha256(body)
and save_attachment freezes kind at first insert, so a byte-identical
preview/upload pair would otherwise share a row - whichever landed
second inherited the other's kind, silently hiding an upload from model
context or materializing preview bytes into a tool turn.
2026-07-07 08:20:57 -07:00
Patrick Buckley 09abc9d199 feat(tools): allow_private_network opt-in for private-address fetch/preview
turnstone's primary audience self-hosts it beside other lab services —
a web_fetch or open_preview aimed at Grafana, Home Assistant, or a dev
node on the local network is the operator using their own network, not
an attack. The hard SSRF refusal made those targets unreachable.

New runtime setting tools.allow_private_network (settings registry,
default off, rendered in console Settings → Tools; hot — read per tool
call, no restart). When enabled, a call NAMING a private address
becomes approvable: the approval prompt tags it "(private network)" so
the operator approves it as what it is, and the human gate stays.

The redirect side-door stays closed either way: a PUBLIC target that
302s into private address space is refused regardless of the opt-in —
that address never appeared on the approval card, so it is never
fetched. Only a chain whose approved origin was itself private skips
hop screening (its redirects are the operator's own network).

Refusals now teach the knob (mirrors the oidc opt-in hint): the error
names tools.allow_private_network and where to enable it. Surfaces
without a ConfigStore (bare CLI, eval) stay strict — there is no admin
surface to have opted in on.
2026-07-07 08:20:57 -07:00
Patrick Buckley 1e2ab91ec2 feat(preview): probe preflight, legacy charsets, remote-assets opt-in, md vendor parity
Four follow-ups to the preview pane:

- Probe-mode preflight: the pane preflights src-loaded kinds with
  GET ?probe=1 (204, real hardening headers, no body) instead of HEAD —
  the console reverse proxy forwards HEAD as a full GET, so the old
  preflight dragged the whole blob across the node→console hop twice.
  Ownership gate + renderable-type check still run on probes.
- Legacy-charset text: table/text/markdown now transcode to UTF-8 at
  store time (declared charset → UTF-8 → cp1252-replace ladder), same
  model the web kind already used. The ladder applies only when the
  text kind was DECLARED (MIME/extension/override); the bare no-hint
  fallback stays strict UTF-8 and NUL bytes still hard-reject, so
  binary rejection is unchanged.
- Remote assets default OFF: previewed pages are now served under
  "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src
  data:; font-src data:" — they render with inline styling but cannot
  contact their origin site (no viewer IP/traffic disclosure). A
  per-pane "Load remote images & styles" checkbox (web previews only,
  sticky, not persisted) reloads with ?assets=1 for the permissive
  bare-sandbox mode.
- Markdown vendor parity: preview markdown now runs renderer.js's
  postRenderMarkdown (hljs token coloring + lazy mermaid diagrams)
  like the conversation pane, with preview-scoped code-block/KaTeX
  chrome (the conversation theme is .msg.assistant-scoped).

Tests: probe/assets HTTP + policy coverage, charset ladder units +
stored-bytes round-trip, JS static guards for the probe form, the
default-off toggle, and the post-pass; headless-chrome harness grew to
41 assertions (probe-not-HEAD, toggle visibility/default, fenced-code
render). Full suite green.
2026-07-07 08:20:57 -07:00
Patrick Buckley e010124008 feat(preview): rich preview pane + open_preview tool
Tool results only ever rendered as plain text in the transcript. This
adds the model-driven rich-preview lane every comparable surface has,
in turnstone's developer-tool idiom: a preview pane that opens BESIDE
the conversation, keyboard-operable, sandboxed, never replacing the
transcript that spawned it.

Backend
- New built-in open_preview(target, kind?, title?): resolves an http(s)
  URL, a file path, or attachment:<id> to bytes; classifies into
  web/pdf/image/table/text/markdown (magic bytes > MIME hint >
  extension > UTF-8 fallback, legacy-charset pages transcoded); caps
  size per kind; persists content-addressed with kind="preview" —
  refcounted and GC'd with the workstream, skipped by trajectory
  reconstruction so preview bytes can never materialize onto the wire.
  URL targets gate like web_fetch (network egress); paths/attachments
  run unprompted like read_file.
- New core.web.fetch_with_ssrf_guard: manual redirect walk that
  SSRF-screens every hop BEFORE requesting it (follow_redirects=True
  checked nothing between hops); adopted by both open_preview and
  web_fetch. URL userinfo is stripped before the descriptor or the
  stored bytes see it; <base href> is injected doctype-safely so
  relative assets resolve without quirks mode.
- The preview descriptor rides the tool turn's meta side channel with
  ONE shape on every boundary: the live tool_result SSE event, the
  conversations.meta column, and the /history projection. Cancelled
  batches commit an already-announced preview (blob + meta) instead of
  stranding the open pane on a permanent 404.
- New GET {ws}/attachments/{id}/preview (read scope, same ownership
  gate as /content) serves the STORED type with per-MIME hardening:
  bare CSP sandbox for text/html (renderable, scriptless, opaque
  origin), no CSP for application/pdf (Chromium's viewer refuses
  sandboxed contexts), full default-src 'none' otherwise; filenames
  fold to latin-1-safe ASCII. The console /node proxy now forwards
  CSP/nosniff/disposition/cache-control instead of dropping them.
- History loads exclude preview blobs from the bulk content fetch at
  the query (they were read and discarded on every load).

Frontend
- New "preview" pane type registered in the shared shell (server +
  console): openPaneBeside placement, per-kind renderers — fully
  sandboxed iframe for pages, browser PDF viewer, sortable tables
  (CSV/TSV/JSON, ragged-file safe, 5k-row cap), rendered markdown,
  text — plus back/forward history with arrow keys, reload persistence
  via pane meta, and backoff auto-retry (0.9s..7.2s) bridging the gap
  between the live descriptor and the batch fold that commits its blob.
- Tool results carrying a descriptor render a credential-redacted
  preview chip (the reopen + replay affordance); live results auto-open
  the pane only while the originating pane holds focus.

Docs: docs/tools.md + prompts/tools.md. Tests: policy unit tests, tool
prepare/exec (mocked fetch), serving route + proxy header pass-through,
storage exclusion on both backends, cancel-path commit, JS static
guards; a headless-Chrome harness drives the real module graph (32 DOM
assertions).
2026-07-07 08:20:57 -07:00
Patrick Buckley 4350248d8f fix(oidc): carry the opt-in hint on discovered-endpoint rejections
The discovered-endpoint wrapper converted every OAuthSSRFError to a
bare OIDCError, so a private-resolving endpoint or trusted host got the
non-public message without the allow_private_network remediation even
though the same knob fixes it. Hoist the hint into a module constant
and append it in both wrappers.

Also name "unspecified" in the refused-even-with-opt-in message so
0.0.0.0/:: rejections read unambiguously.
2026-07-06 21:52:42 -07:00
Patrick Buckley 9c74673fd4 feat(oidc): allow_private_network opt-in for self-hosted IdPs
The SSRF guard on OIDC endpoint URLs hard-refused any hostname
resolving to a non-public address, which made it impossible to use a
self-hosted IdP (Keycloak, Authentik, Dex) on an internal network —
even though the login-flow issuer is operator-configured, i.e. trusted
input.

Add [oidc] allow_private_network in config.toml (or
TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK), default off. When set, the
issuer and its discovered endpoints may resolve to private-range,
unique-local, CGNAT, and loopback addresses. Link-local, multicast,
unspecified, and reserved ranges stay refused regardless — cloud
metadata services live on link-local and no legitimate IdP does. The
HTTPS requirement and same-origin endpoint checks are unchanged.

The private-address refusal now raises OAuthSSRFPrivateAddressError,
and the OIDC wrapper appends the remediation hint to the error message
so the failure is self-service. mcp_oauth call sites — where endpoint
URLs come from untrusted remote-server metadata — do not get the knob
and keep the strict public-address rule.
2026-07-06 21:52:42 -07:00
Patrick Buckley 19b1a04f17 fix(redaction): match connection-string schemes case-insensitively
RFC 3986 schemes are case-insensitive, so POSTGRESQL+PSYCOPG2:// or
HTTPS://user:pass@host in tool output leaked the password past the
case-sensitive scheme alternation. Compile with IGNORECASE on both
sides of the FE/backend mirror; the structural userinfo requirement
is unchanged. Uppercase-scheme cases added to both test suites.
2026-07-06 21:42:51 -07:00
Patrick Buckley ed2623ff44 fix(redaction): match SQLAlchemy driver schemes; add FE prefilter bailout
Connection-string redaction (the output_guard pattern and its frontend
mirror) only enumerated bare dialects plus +psycopg, so SQLAlchemy
dialect+driver URLs — postgresql+psycopg2://, postgresql+asyncpg://,
mysql+pymysql:// — leaked the password through every redaction surface.
The scheme now takes an optional +suffix instead of enumerating drivers.

redactCredentials() also gains a single early-exit prefilter scan ahead
of its sixteen replace passes, for plain-log tool output on card render.
The prefilter is documented and pinned as a superset of the pattern
set's required substrings, so a miss is provably a no-op: new smoke
cases assert bare sk-/AKIA/Bearer credentials with no '=', quote or '@'
anywhere in the text still redact, alongside the fast-path no-op and
the driver-scheme URLs on both sides of the mirror.
2026-07-06 21:42:51 -07:00
Patrick Buckley 625218b7b5 docs: price the learned veto's influence channels in HYPOTHESIS.md
- Proven: name supervisory control's controllability and nonblocking
  conditions as the ancestors of gate-early-on-irreversibles and the
  always-enabled escalation required behind a learned veto; cite TCSEC
  covert-channel analysis (NCSC-TG-030) for the verdict channel.
- Asserted: add the narrow-only rule's influence-side twin (verdict
  payloads to the plant selected, never generated).
- New caveat paragraph: a denial is free only in the authority lattice;
  in the dynamics it is an input (selection + targeted-liveness
  channels), so a learned veto needs a nonblocking escape it cannot
  disable, verdict payloads are selected rather than generated with the
  symbols/tokens/language thresholds bounding the alphabet, and the
  strongest form dissolves the verdict into scheduling over
  deterministic checks.
2026-07-06 21:15:04 -07:00
Patrick Buckley a029849724 fix(models): keep raw exception text out of client-construction 503s
Review: the wrapped ValueError is echoed in 503 bodies, and arbitrary
SDK exception text can embed filesystem paths. Echo the exception type
only; log the full exception with traceback at the raise site.
2026-07-06 21:10:27 -07:00
Patrick Buckley 9c2e809b26 fix(models): surface client-construction failures as factory misconfig
SDK client construction can fail on environment problems the config
never sees (e.g. httpx resolving a certifi CA path deleted by a venv
rebuild). Those escaped as bare exceptions and turned every workstream
open/create into an opaque 500; re-type them as ValueError in
ModelRegistry.get_client so routes answer 503 with the message and the
alias.
2026-07-06 21:10:27 -07:00
Patrick Buckley 51ed336989 fix(storage): survive oversized rows in postgres history search
to_tsvector was computed inline over full row content, so one row
whose tsvector exceeds PostgreSQL's 1MB limit aborted every
search_history scan. Cap the FTS input at 250K chars (worst-case
tsvector expansion stays under the limit; giant rows remain findable
by their head). The ILIKE fallback also never ran on postgres: the
failed statement leaves the autobegun transaction aborted, so roll it
back before falling back.
2026-07-06 21:10:27 -07:00
Patrick Buckley 90663ce695 fix(core): defer tool deepcopy until a description actually changes
Address review on #794. The prior gate deepcopied every agent tool, then compared, then discarded the copy on a no-op render; and its comment framed the equality case as 'no personas' when it also covers an idempotent re-render of unchanged aliases/personas. Compute the target model/persona descriptions from the current (read-only) schema first and only deepcopy when one differs — so a no-op render is genuinely allocation-free, not just fork-free. Behaviour is unchanged: identity preserved when nothing differs, stale text still cleared on reload.
2026-07-06 21:00:21 -07:00
Patrick Buckley bd37bcd1ec fix(core): keep agent-tool render idempotent so no-persona sessions share the tool constant
_render_agent_tool_descriptions rebuilt self._tools and reassigned it on every session init, deep-copying task_agent even with no model aliases and no personas to inject — the single-model CLI case the docstring says is skipped. This regressed after the persona-discoverability change removed the early 'if self._registry is None: return' guard, breaking the session._tools is INTERACTIVE_TOOLS invariant (test_session_without_mcp).

Gate the reassignment on whether a description actually changed: keep the original tool object when the render is a no-op, fork self._tools only when something was injected. Restores the shared-constant invariant, makes repeated renders idempotent, and preserves clear-stale-on-reload (an emptied registry still takes the changed path).
2026-07-06 21:00:21 -07:00
Patrick Buckley a61d454df5 docs: add funding button (GitHub Sponsors + PayPal)
Add .github/FUNDING.yml to enable the native GitHub Sponsor button, plus a Sponsor badge and a Support section in the README. Primary CTA is GitHub Sponsors (eous); PayPal (paypal.me/eousphoros) is offered as a one-off fallback.
2026-07-06 20:28:52 -07:00
Patrick Buckley 647939fe4d fix(personas): repr the input in the not-found resolver error
Review feedback on #792: the "not found or disabled" branch
interpolated the raw input unquoted, so the whitespace-only and
trailing-space inputs the forgiving lookup explicitly handles rendered
invisibly in CLI output and logs. Use {name!r} like the other two
resolver errors already do.
2026-07-06 19:58:36 -07:00
Patrick Buckley 457b01737a feat(personas): agent discoverability + forgiving name resolution
Coordinators and interactive agents had no way to enumerate valid
persona names: task_agent / spawn_workstream / spawn_batch described
`persona=` but nothing listed what it accepts, and resolution was an
exact case-sensitive slug match - users reaching for the display name
or a case variant got an unexplained failure.

- Inject the live persona catalog (enabled, interactive-kind; children
  and sub-agents are always interactive) into the `persona` parameter
  description of task_agent / spawn_workstream / spawn_batch, riding
  the same render path as the model-alias injection. Rebuilt from the
  pristine TOOLS base every render, so repeated renders are idempotent
  and archived personas drop out instead of lingering. Entries carry
  name + default marker + <=96-char description; names-only past 25
  personas. spawn_batch's persona property is nested per-child under
  children.items.properties (located via _persona_property, null-safe
  against name-colliding MCP tools). Storage-less sessions keep the
  base text: the render runs at session construction, so it gates on
  is_storage_initialized() rather than get_storage(), which would
  auto-init SQLite as a side effect.

- resolve_persona_for_kind - the ONE shared rule behind the HTTP
  create handler, CLI --persona, the coordinator spawn precheck, and
  task_agent prep - is now forgiving: exact slug, then the lowercased
  input (created names are regex-enforced lowercase slugs), then a
  case-insensitive display-name match accepted only when unique among
  the kind's enabled personas. Duplicates refuse loudly naming the
  candidate slugs; a same-label persona of another kind neither blocks
  nor wins (the label the caller saw came from a kind-filtered
  surface); whitespace-only input never matches blank display names
  (display_name defaults to ""). Every failure now enumerates the
  kind's valid names, so a stale injected list or a typo self-corrects
  on the next attempt.

- The canonical slug is stamped everywhere: task_agent prep rewrites
  its arg from the resolved snapshot, _validate_child_persona returns
  (canonical, error) and both spawn call sites adopt it - approval
  chrome, the wire, and workstream_config never carry a forgiven
  variant.

- Create-persona shelf: label hint under Name explaining agents and
  the CLI launch the persona by this name (case-insensitive) and the
  display name is only a list label. docs/personas.md gains a "How
  agents discover personas" section and drops the stale claim that
  task_agent has no persona parameter.

Tests: resolver unit suite (case/display/ambiguity/cross-kind/
whitespace/disabled/storage-failure) + guards for injection content
and ordering, idempotent re-render, archive drop, the 25-persona
prose cutoff, coordinator-kind exclusion, and canonical stamping
through spawn_workstream / spawn_batch / task_agent.
2026-07-06 19:58:36 -07:00
Patrick Buckley a40ff249ec fix: address review — request-scoped storage in coord tenancy checks
- _coordinator_tenant_check and _coord_attachment_owner resolved storage from
  the global registry (get_workstream_row / for_request without a storage arg),
  which can evaluate the project-tenancy decision against a different or
  auto-initialised backend and fail OPEN on a missing project row. Use
  request.app.state.auth_storage explicitly, matching cluster_ws_detail and
  _resolve_coordinator_or_404; fail closed (404) when it is unset.
- reject_unassignable_scopes now derives its allowed-scope error message from
  ASSIGNABLE_SCOPES so validation and the message can't drift.
2026-07-06 19:16:09 -07:00
Patrick Buckley 36419a9809 fix: scope private-project workstream visibility to members, not admins
Workstreams attached to a private project were visible -- including their
conversation content -- to holders of admin.cluster.inspect / admin.coordinator
(both default builtin-admin permissions), defeating the project's confidentiality
boundary. Enforce that a private project's resources are visible only to people
IN the project (owner, workstream creator, or an explicit member), even for admins.

Surfaces closed:

- WorkstreamProjectVisibility bypass narrowed to service scope only (node->console
  machine plumbing, re-filtered per-user at the console edge). No human principal
  bypasses; admin.cluster.inspect gates the inspect surfaces, not tenancy. This
  flows to /dashboard, session listings, the attachment row-gate, cluster_workstreams,
  cluster_node_detail, and cluster_snapshot/SSE.
- cluster_ws_detail 404-masks a workstream in a private project the caller can't
  see; cluster_ws_live_bulk routes such ids to the denied list (no private-project
  oracle).
- Coordinator operator verbs (history/export/detail/send/approve/set_title/open/
  children/tasks/attachments) now enforce project tenancy: _coordinator_tenant_check
  on coord_endpoint_config, the gate in _resolve_coordinator_or_404 (children/tasks),
  the tenant_check now run in make_open_handler before rehydrate, and a
  project-visibility check in _coord_attachment_owner. admin.coordinator gates the
  surface cluster-wide, but a non-member is 404-masked. The tenant-check mirrors the
  manager-first + coordinator-kind ladder so kind-isolation is preserved.
- service scope is no longer user-assignable: admin_create_token and both
  turnstone-admin CLI mint paths reject it via reject_unassignable_scopes, so an
  admin.users holder cannot self-mint a service token and restore the bypass. Service
  scope is minted only by ServiceTokenManager / the JWT secret.
- The events/global node proxy (service-elevated cross-tenant firehose) is gated on
  admin.cluster.inspect so a plain authenticated user cannot reach it through the
  console proxy.

Updates the OpenAPI description, the row-gate/tenancy-filter docstrings, and adds
tests for every surface (visibility predicate + cluster detail/bulk + coordinator
history/export/children/open/attachments + events/global proxy + scope-mint
rejection); inverts the tests that pinned the old admin-bypass contract.
2026-07-06 19:16:09 -07:00
Patrick Buckley 0c2c534c86 fix(mcp): route pool transport lifecycles through per-entry owner tasks (#788)
* fix(mcp): route static transport lifecycles through per-server owner tasks

A crash-looping MCP server drove the mcp-loop thread to a sustained,
climbing 100%+ CPU spin. Root cause: anyio cancel scopes are
host-task-bound, and the static path entered the SDK's transport /
ClientSession task-group scopes from short-lived connect tasks (every
health tick is a new task since #768). Once such a scope was cancelled
after its host task had finished - by anyio's task_done when a
transport child died with the server, or by ClientSession.__aexit__
during a cross-task teardown - CancelScope._deliver_cancellation could
never make progress (task.cancel() on a done task is a no-op) and
re-armed itself via call_soon every loop iteration, forever: ~900k
callbacks/s per zombie scope, one more per flap cycle (verified against
anyio 4.14.1; no upstream fix exists as of that release).

Fix: each static server's transport + session cms are now entered,
parked, and exited by ONE long-lived owner task
(_static_transport_owner), so scopes always have a live host and always
exit in the task that entered them. Teardown follows a one-cancel close
protocol (signal the close event before the first await, graceful
grace, then at most ONE cancel - never a second, which would abandon a
scope exit mid-flight). Connect timeouts now cancel only the waiting
caller; connect failures are delivered through a readiness future;
unrequested owner death (server died under a live session) evicts the
session immediately via a done-callback instead of waiting for the next
liveness ping. A rate-limited, mcp-loop-scoped gc-walk backstop
(_maybe_disarm_orphaned_scopes) disarms any zombie minted by paths not
yet migrated (the oauth_user pool keeps the old cross-task-close shape;
follow-up).

Also fixed: BaseExceptionGroup (BaseException-derived, as raised by
anyio task groups wrapping a stray CancelledError, e.g. an
accept-then-RST server) escaped `except Exception` in _connect_all and
killed it before the health/sweep loops were created - silently
disabling all autonomous recovery. Handled there and in the
health/sweep/eviction loops and the reconnect/refresh callers.

Verified: a live SIGKILL-flap repro went from 130%+ CPU (climbing, one
armed scope per cycle) to 0.3% flat with zero armed scopes; the RST
repro now leaves both background loops alive (previously both silently
dead). New tests: owner-lifecycle + close-protocol units (incl. an
exactly-one-cancel pin), a _connect_all BaseExceptionGroup regression,
a discriminating disarm-sweep test, and a ~10s live SIGKILL-flap smoke
test (real FastMCP subprocess, skips on environment gaps) asserting
zero armed scopes, exactly one live owner, and a post-recovery tool
call. Full 8470-test suite green; ruff+mypy clean.

* fix(mcp): route pool transport lifecycles through per-entry owner tasks

Completes the owner-task migration started for the static path: the
oauth_user pool path had the same latent anyio cancel-scope exposure
(host-task-bound scopes entered by short-lived connect tasks; a scope
cancelled after its host finished re-delivers cancellation via
call_soon forever - the 100%-CPU zombie), previously covered only by
the disarm backstop.

Each (user, server) pool entry's transport + ClientSession cms are now
entered, parked, and exited by ONE long-lived owner task
(_pool_transport_owner). The caller keeps building client_kwargs (the
per-user bearer and, when an auth-capture carrier is active, the
httpx_client_factory response hook) so 401/WWW-Authenticate capture
semantics are unchanged. Teardown is the shared one-cancel close
protocol (_teardown_pool_entry: signal before first await, graceful
grace, at most ONE cancel), used by the connect stale-guard, idle/LRU
eviction, and shutdown (parallel signal-then-reap). Unrequested owner
death evicts the session but keeps the entry and its discovered
catalog, matching the existing evict-session-keep-entry semantics the
auth_401 retry relies on.

Discovery still runs in the connecting caller while the transport is
hosted by the owner, so a transport collapse mid-discovery (e.g. the
SDK tearing its task group down on an upstream 401) cancels the OWNER,
not the caller - a bare await on the response stream would hang until
the 30s phase timeout. _await_pool_discovery races each discovery
await against owner completion and converts owner death into a prompt
ConnectionError (the owner is never cancelled there; teardown owns its
lifecycle). Carrier-first failure classification preserves auth_401
semantics for captured 401s.

With no cross-task stack closes left, _safe_close_stack and
_safe_teardown_on_connect_failure are deleted (zero callers).

Tests: new tests/test_mcp_pool_owner.py pins the pool close protocol
(graceful event-before-await close, exactly-one-cancel escalation,
owner-death eviction retaining entry+catalog, caller-cancel-mid-connect
cm-exit guarantee, factory-present-iff-capture, and the
owner-death-during-discovery fast-fail). 1010 mcp tests and the full
8471-test suite green, including the historical cross-task-anyio
sentinel test_integration_pool_reuse_401_refresh_and_retry_succeeds;
ruff+mypy clean; zero destroyed-task warnings.

* fix(mcp): harden disarm-sweep loop guard and owner BaseException arm

Review follow-ups on the owner-task migration:

- _maybe_disarm_orphaned_scopes now enforces its mcp-loop requirement
  instead of trusting callers: it returns without walking (and without
  advancing the rate-limit clock) unless the currently running loop IS
  self._loop. A suppressed close can fire before start() or after
  shutdown(), where the walk would be wasted at best and a cross-thread
  reach at worst.

- The transport owner's BaseException arm now re-raises non-Exception,
  non-group escapees (KeyboardInterrupt, SystemExit) after delivering
  them to the readiness future - failure delivery is the arm's job;
  swallowing an interpreter-level exit was not.

* fix(mcp): extend owner-death discovery fast-fail to the static path

The static connect path had the same exposure the pool's discovery race
closed: discovery runs in the connecting caller while the transport is
hosted by the owner task, so a transport collapse mid-discovery cancels
the OWNER and the caller's bare await on the response stream hung until
the caller-side attempt timeout (~45s) instead of failing promptly.

_await_pool_discovery is renamed to _await_owner_discovery (it is now
path-neutral) and wired into _connect_one_locked's four discovery
awaits. The helper also converts a discovery future that completes
CANCELLED without the race's own reap (an SDK-internal cancellation
shape) into the same ConnectionError, instead of leaking a bare
CancelledError the caller would misread as its own cancellation.

The pool transport owner's BaseException arm gains the same refinement
the static owner received in review: interpreter-level exits
(KeyboardInterrupt, SystemExit) re-raise after delivery to the
readiness future instead of being swallowed.

Tests: static owner-death-during-discovery fast-fail (<1s vs the ~45s
hang), and a direct pin on the cancelled-discovery-future conversion.

* fix(mcp): replace owner BaseException arm with targeted catch + finally delivery

The owner's failure arm now catches only (BaseExceptionGroup, Exception);
waiter delivery for everything else moves to a finally that resolves the
readiness future with a clean transport-failure ConnectionError before
the task unwinds. Interpreter exits and BaseException-derived library
control-flow escapes propagate from the owner exactly once, uncaught -
and the waiter can never be left hanging on an unresolved future (the
initial _connect_all connect has no outer bound). For SystemExit /
KeyboardInterrupt asyncio additionally stops the loop right after, so
the delivery is load-bearing for the non-exit BaseException shapes and
free for the exits.

Pinned by a test driving a BaseException-derived escape through the
owner: the waiter resolves promptly with ConnectionError while the
escape propagates unswallowed.

* fix(mcp): mirror targeted-catch + finally delivery in the pool owner

Same shape the static owner received in review: the failure arm catches
only (BaseExceptionGroup, Exception), and waiter delivery for anything
else moves to a finally that resolves the readiness future with a clean
ConnectionError before the task unwinds - interpreter exits and
BaseException-derived library escapes propagate exactly once, uncaught,
and the waiter can never be left hanging.

* test(mcp): narrow the escape test's waiter catch to explicit types

* test(mcp): narrow discovery-race waiter catches to explicit types

* refactor(mcp): make reap/synchronization awaits explicit to analyzers

Full-absorb reaps (cancel-then-drain of a future whose outcome is
deliberately consumed) become `await asyncio.gather(x,
return_exceptions=True)` - one line, self-describing, and in the
owner-died discovery reap it is also a small semantic improvement: a
caller cancellation arriving during the reap now propagates instead of
being masked by the ConnectionError. Bare synchronization awaits and
selective suppress blocks in tests keep their raise-through semantics
via throwaway assignment. Applied uniformly across the owner-task
test files, including sites introduced by the static-path PR.
2026-07-06 18:46:28 -07:00
renovate[bot] 5fded65b82 chore(deps): lock file maintenance 2026-07-06 18:20:14 -07:00
Patrick Buckley 7da731cbe1 test(mcp): narrow the escape test's waiter catch to explicit types 2026-07-06 18:19:17 -07:00
Patrick Buckley 8ed86ae7ab fix(mcp): replace owner BaseException arm with targeted catch + finally delivery
The owner's failure arm now catches only (BaseExceptionGroup, Exception);
waiter delivery for everything else moves to a finally that resolves the
readiness future with a clean transport-failure ConnectionError before
the task unwinds. Interpreter exits and BaseException-derived library
control-flow escapes propagate from the owner exactly once, uncaught -
and the waiter can never be left hanging on an unresolved future (the
initial _connect_all connect has no outer bound). For SystemExit /
KeyboardInterrupt asyncio additionally stops the loop right after, so
the delivery is load-bearing for the non-exit BaseException shapes and
free for the exits.

Pinned by a test driving a BaseException-derived escape through the
owner: the waiter resolves promptly with ConnectionError while the
escape propagates unswallowed.
2026-07-06 18:19:17 -07:00
Patrick Buckley ed30e4f0bf fix(mcp): harden disarm-sweep loop guard and owner BaseException arm
Review follow-ups on the owner-task migration:

- _maybe_disarm_orphaned_scopes now enforces its mcp-loop requirement
  instead of trusting callers: it returns without walking (and without
  advancing the rate-limit clock) unless the currently running loop IS
  self._loop. A suppressed close can fire before start() or after
  shutdown(), where the walk would be wasted at best and a cross-thread
  reach at worst.

- The transport owner's BaseException arm now re-raises non-Exception,
  non-group escapees (KeyboardInterrupt, SystemExit) after delivering
  them to the readiness future - failure delivery is the arm's job;
  swallowing an interpreter-level exit was not.
2026-07-06 18:19:17 -07:00
Patrick Buckley 62f62ae624 fix(mcp): route static transport lifecycles through per-server owner tasks
A crash-looping MCP server drove the mcp-loop thread to a sustained,
climbing 100%+ CPU spin. Root cause: anyio cancel scopes are
host-task-bound, and the static path entered the SDK's transport /
ClientSession task-group scopes from short-lived connect tasks (every
health tick is a new task since #768). Once such a scope was cancelled
after its host task had finished - by anyio's task_done when a
transport child died with the server, or by ClientSession.__aexit__
during a cross-task teardown - CancelScope._deliver_cancellation could
never make progress (task.cancel() on a done task is a no-op) and
re-armed itself via call_soon every loop iteration, forever: ~900k
callbacks/s per zombie scope, one more per flap cycle (verified against
anyio 4.14.1; no upstream fix exists as of that release).

Fix: each static server's transport + session cms are now entered,
parked, and exited by ONE long-lived owner task
(_static_transport_owner), so scopes always have a live host and always
exit in the task that entered them. Teardown follows a one-cancel close
protocol (signal the close event before the first await, graceful
grace, then at most ONE cancel - never a second, which would abandon a
scope exit mid-flight). Connect timeouts now cancel only the waiting
caller; connect failures are delivered through a readiness future;
unrequested owner death (server died under a live session) evicts the
session immediately via a done-callback instead of waiting for the next
liveness ping. A rate-limited, mcp-loop-scoped gc-walk backstop
(_maybe_disarm_orphaned_scopes) disarms any zombie minted by paths not
yet migrated (the oauth_user pool keeps the old cross-task-close shape;
follow-up).

Also fixed: BaseExceptionGroup (BaseException-derived, as raised by
anyio task groups wrapping a stray CancelledError, e.g. an
accept-then-RST server) escaped `except Exception` in _connect_all and
killed it before the health/sweep loops were created - silently
disabling all autonomous recovery. Handled there and in the
health/sweep/eviction loops and the reconnect/refresh callers.

Verified: a live SIGKILL-flap repro went from 130%+ CPU (climbing, one
armed scope per cycle) to 0.3% flat with zero armed scopes; the RST
repro now leaves both background loops alive (previously both silently
dead). New tests: owner-lifecycle + close-protocol units (incl. an
exactly-one-cancel pin), a _connect_all BaseExceptionGroup regression,
a discriminating disarm-sweep test, and a ~10s live SIGKILL-flap smoke
test (real FastMCP subprocess, skips on environment gaps) asserting
zero armed scopes, exactly one live owner, and a post-recovery tool
call. Full 8470-test suite green; ruff+mypy clean.
2026-07-06 18:19:17 -07:00
renovate[bot] 5ae6c2316f chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.27 2026-07-06 18:03:22 -07:00
renovate[bot] d793adb24c chore(deps): update anthropics/claude-code-action digest to f87768c 2026-07-06 18:02:58 -07:00
renovate[bot] 3cf94dd80f chore(deps): lock file maintenance 2026-07-06 03:09:51 -07:00
renovate[bot] 0422f9214a chore(deps): update github actions 2026-07-06 03:09:35 -07:00
Patrick Buckley 4428e185e5 switch qwen to nvidia/Qwen3.6-27B-NVFP4 with MTP 2-token spec-decode
- Model: nvidia/Qwen3.6-27B-NVFP4 (FP4 4-bit, ~13.5 GiB weights)
- MTP speculative decoding: method=mtp, num_speculative_tokens=2
- runai_streamer for ~26x faster weight loading
- max-num-seqs bumped from 2 to 8 for throughput under concurrent load
- Added compile cache volume mounts (triton, torch inductor, flashinfer)
- Updated ROCm guidance to recommend Qwen/Qwen3.6-27B-FP8
- Removed --kv-cache-dtype fp8 (default fp16 is fine at 0.50 util)
2026-07-05 17:13:08 -07:00
Patrick Buckley c5ff3147ce fix: cover secret_access_key/aws_secret_access_key multi-segment key patterns
The bounded key prefix pattern (api_key=/secret_key= etc.) avoids
monkey/turkey false positives, but compound keys like secret_access_key
and aws_secret_access_key only matched on the access_key= suffix, leaking
the secret_ / aws_secret_ prefix. Added these as explicit alternations.

Also added bearer_token and secret_token to the token prefix list.
2026-07-05 16:09:07 -07:00
Patrick Buckley bfcfb0c791 fix: restore bare token=/key= matching with negative lookbehind to avoid monkey FP
Bare alternatives (|token, |key) for standalone token= and key= assignments
were re-added.  A negative lookbehind (?<![a-zA-Z0-9_]) prevents matching
word-suffixed identifiers like monkey=, turkey=, mytoken=, over_tokenized=.
Also applied the same protection to _RE_QUERY_CRED which had a bare |token
alternative without boundary protection.
2026-07-05 16:09:07 -07:00
Patrick Buckley cbf5c5f3b6 Fix false positives and perf issue in credential redaction
- Replace unbounded [a-zA-Z0-9_]*key= prefix with specific credential
  key suffix alternation to avoid false matches on monkey=, turkey=, etc.
  Same for *token= (access_token=/auth_token= but not over_tokenized=).
- Hoist redactCredentials() out of per-line diff render loop in
  buildConvCmd - 1 call on full text instead of N calls per line,
  eliminating ~2800 regex passes worst-case.
- Remove bare 'key' from _RE_QUERY_CRED alternation (too aggressive).
- Add x-api-key / x_api_key to JSON secret key lists in both Python
  and JS.
- Add re.IGNORECASE to configurable-mode credential_bearer pattern.
- Fix annotation dedup guard in _check_credentials (was checking flag
  name against annotation prose list, always-true dead code).
2026-07-05 16:09:07 -07:00
Patrick Buckley 31a1d5c3ee fix(redact): harden credential redaction and restore JS/backend parity
Address review findings on the client-side credential redactor and mirror
each fix into the backend output guard so both surfaces censor identically:

- Detect and redact single-quoted JSON secrets such as
  {'Authorization': 'Bearer ...'} (Python dict reprs / JS object literals),
  which the double-quote-only pattern silently bypassed on both sides.
- Cover mongodb+srv://, rediss:// and amqps:// connection strings.
- Match the Bearer auth scheme case-insensitively (RFC 7235).
- Redact prefixed key/token assignments (api_key=, secret_key=,
  access_token=) as a whole rather than chewing the tail into a garbled
  "api_[REDACTED:api_key]", while still covering bare key=/token=. A word
  boundary was rejected because it would drop coverage for <prefix>_key=.
- Remove the redundant |Authorization alternative (covered by /i) and swap
  the manual value-slicing helper for a capture-group substitution.

The backend edits touch both detection sites and both redaction pipelines,
so single-quoted secrets are flagged (and therefore sanitized), not merely
rewritten. Adds JS runtime-smoke and backend unit coverage for every case.
2026-07-05 13:26:32 -07:00
Patrick Buckley 93a7486cc2 Add client-side credential redaction for tool call cards
New shared ES6+ module (redact_credentials.js) provides comprehensive
visual credential censorship matching the backend output guard patterns:

  - PEM private key blocks, connection strings, Bearer tokens
  - OpenAI / GitHub / AWS / Google API key formats
  - Query-string and JSON-style credential values
  - JSON secret keys (api_key, password, token, authorization, etc.)
  - ENV secret lines (SECRET_KEY=, DATABASE_URL=, etc.)

Integrated into both frontend surfaces:
  - interactive.js: replaces legacy minimal _redactApiKeys function
  - conversation.js::buildConvResult (shared substrate, used by coordinator)
  - coordinator.js::renderToolOutput fallback paths

Backend parity: added 'authorization' to the JSON secret regex in
output_guard.py so the output guard flags and redacts Authorization
headers in JSON tool output.

Tests: ported the runtime smoke test from the removed _redactApiKeys
to import the new module directly; added redact_credentials.js to the
var-free and const-reassign guard bundles.
2026-07-05 13:26:32 -07:00
Patrick Buckley 56624f9597 fix(core): scrub credentials and control chars from tool-args log preview
`tool_args_preview` feeds `stream.tool_args_malformed` (WARNING) and
`wire.tool_args_legalized` (DEBUG), and tool arguments are model/user
controlled — they can carry secrets (a token in a bash command, a password in a
connection string) or raw CR/LF that break log lines. Route the preview through
`output_guard.redact_credentials` over the full value first (before the 120-char
cap, so a secret straddling the cut isn't half-shown past the pattern's reach),
then collapse every control char to a space, mirroring `audit._scrub_string`.

Addresses the PR review comments.
2026-07-05 11:59:52 -07:00
Patrick Buckley 16a68ae6d6 fix(core): legalize malformed tool-call arguments before the wire
A tool call whose `arguments` is not a JSON-object string (an unterminated
string from a non-`length` truncation, or an empty `""` from a no-arg call)
was committed verbatim and replayed on every subsequent send. Strict renderers
that re-parse arguments at render time (vLLM's `deepseek_v4`
`_postprocess_messages` runs `json.loads` on them) reject the whole request
with HTTP 400, wedging the conversation. The only prior guard dropped partial
tool calls on `finish_reason == "length"`; a `stop`/`tool_calls` finish reason
carrying invalid JSON slipped through, and its synthetic "retry" result kept it
from being an orphan, so the orphan-repair pass never touched it.

Add `sanitize_tool_call_arguments`, a wire-neutral legalize pass in lowering
(fold, legalize, repair), normalizing any non-JSON-object `arguments` to `{}`
on the transient wire copy only. The canonical trajectory keeps the raw model
output, so a wedged session self-recovers on its next send. A
`wire_valid_arguments` predicate is shared with a non-destructive
`stream.tool_args_malformed` warning at the stream accumulator, which surfaces
the model-quality problem at production time.

Convert `lowering.py` to structlog so the new pass emits structured events.
2026-07-05 11:59:52 -07:00
Patrick Buckley 2d4cb6fea9 fix(ui): make pane hotkeys work off macOS and match across surfaces
The pane/workstream accelerators only worked on macOS. They were bound to
Ctrl, which on Windows/Linux IS the browser's own accelerator: Ctrl+T,
Ctrl+W and Ctrl+1-9 were swallowed by the browser (new tab / close tab /
switch tab) and never reached the page. macOS browsers own Cmd instead, so
Ctrl was free there and everything appeared to work.

On top of that the shortcuts were declared in three places that had drifted
apart — the "?" overlay, each app.js keydown handler, and the tab-menu
badges in shell.js. The console fell to convTabMenu's node-proxy fallback
lane, which dropped every shortcut badge (and Fork), so its tab menu showed
no accelerators and Ctrl+W there just closed the browser tab.

Choose the modifier per platform (Ctrl on macOS, Alt on Windows/Linux) and
make shell.js the single source of truth for the per-pane accelerators: a
stable accel registry drives both the platform-aware badge and one shared
keydown handler that invokes the ACTIVE pane's own menu item, so a badge
can't advertise a chord the handler ignores and each surface contributes
only what it supports (the console omits Fork; it has no fork surface yet).

Each surface's app.js keeps only its global accels (new / switch /
dashboard); the console regains switch + dashboard to match. Mod+W now
uniformly means Close pane (drop the tab, session keeps running), matching
its badge and the universal Ctrl+W convention — previously the standalone's
Ctrl+W stopped the session. The previously-dead "Refresh title / Ctrl+Shift+R"
is wired, and Ctrl+T / Ctrl+D yield to text editing while a field is focused
(macOS transpose / delete-forward).
2026-07-05 09:09:34 -07:00
Patrick Buckley 357d00400e docs(changelog): document the 1.7.0 stable release 2026-07-05 06:37:44 -07:00
Patrick Buckley 3615f98c19 Fix send button stuck disabled by pruning orphaned approval cycles (#775)
* Fix send button stuck disabled by pruning orphaned approval cycles

When a DOM wipe (clear_ui / replay_truncated / replaceChildren)
detaches approval card elements while an approve_request event is
processed between the wipe and refetch-restore, the matching
approval_resolved may never arrive. The orphaned cycle entry in
approvalCycles keeps pendingApproval=true and the send button
disabled forever.

The fix adds a pruning pass at the top of _syncApprovalState():
cycles whose blockEls are all .isConnected === false are deleted
from the Map. This runs on every register/resolve/rebuild so
orphans are cleaned up promptly.

Also fixes an ordering bug in showInlineToolBlock discovered
during review: the block element was appended to the DOM after
_registerApprovalCycle, so the new isConnected prune would kill
the just-registered cycle before it took effect.

* Fix comment inaccuracy in showInlineToolBlock append-before-register guard

The comment said 'blockEls.every(el => el.isConnected)' but the
actual prune check is '!blockEls.some(el => el.isConnected)' -
no block elements are connected, not every element.
2026-07-05 06:03:40 -07:00
Patrick Buckley 801d5dfb59 chore: bump version to 1.7.0rc1 2026-07-05 02:03:18 -07:00
Patrick Buckley a352b20786 chore: bump version to 1.7.0a7 2026-07-05 02:02:16 -07:00
Patrick Buckley 6f8efaa44e test(golden): freeze the anthropic-compatible reasoning-effort wire
The wire-payload golden matrix had no anthropic-compatible coverage —
both AnthropicProvider rows are the native lane (compat=False), so the
distinct compat wire shape (reasoning control in
extra_body.chat_template_kwargs, never the native thinking param) was
unfrozen. Add the compat lane across all eight representative fixtures
with a manual-mode capability (the lane has no static table, so caps
ride in as a model definition would supply them) and reasoning_effort=
high: every golden now pins {enable_thinking: true, reasoning_effort:
high} in chat_template_kwargs, asserts the native thinking param is
absent, and preserves temperature (no forced 1.0). _capture gains an
optional caps override to support the no-static-table lane.
2026-07-05 01:59:38 -07:00
Patrick Buckley 9c90fe2722 fix(console): distinct effort label for native-adaptive none vs local toggle
Copilot review caught the native Anthropic adaptive ladders (sonnet-5,
fable-5, opus-4.8/4.7/4.6) labeling the none position 'None — sends
adaptive' — raw mode vocabulary the plain-language rule exists to
prevent. But treating the 'adaptive' token identically to the local
lane's 'on' would still misframe it: on these models thinking is always
on and an effort level rides output_config on every graded position
(verified on the wire), so 'thinking stays on' is true everywhere and
not what distinguishes none. The none position uniquely means no effort
is pinned — the model self-regulates it — so it now reads 'None — model
sets effort'. The local adaptive lane's bare toggle keeps 'thinking
stays on' (no effort lever exists there).
2026-07-05 01:59:38 -07:00
Patrick Buckley 1035fe05eb fix(console): effort annotations say in plain words what the request carries
Aliased knob positions were labeled after the lowest sibling sharing
their wire token — a toggle-only model rendered 'Max (= minimal)',
implying a minimal-effort downgrade the wire doesn't contain, and with
declared values 'High (= minimal)' while the wire carries high. Each
position now states its delivered level: exact matches stay plain
('Max'), snapped positions say 'Low — sends high', the adaptive none
position warns 'thinking stays on', budget detail stays in the
tooltip. Effort-param placeholder corrected to the real graded keys
(reasoning_effort / reasoning).
2026-07-05 01:59:38 -07:00
Patrick Buckley 530958e06b fix(providers): the session effort level always reaches the local-lane wire
Local lanes dropped the knob's graded value unless the operator declared
reasoning_effort_values (and, on the template channel, an effort key) —
picking Max sent a bare thinking toggle and the effort select
degenerated into seven positions that all meant 'on'. The user's
setting now always rides:

- openai-compatible: the flat reasoning_effort param carries the knob
  verbatim (effort_passthrough on the lane default); declared values
  still snap ordinally, and a declared effort_param still claims the
  template channel and suppresses the flat param.
- anthropic-compatible: the graded value rides chat_template_kwargs
  alongside the toggle whenever reasoning control is engaged — under
  the operator's effort_param, else the conventional fallback key
  (reasoning_effort); templates that don't reference the kwarg ignore
  it. thinking_mode=none still injects nothing.
- Commercial lanes untouched: empty declared values still mean 'no
  effort control' (o1-mini) and the ordinal snap is unchanged.

Golden writer now pins ensure_ascii=False: the baselines' literal em
dashes came from a hand edit (03f82521) the default-escaping writer
could never reproduce — regens no longer churn unrelated lines.
2026-07-05 01:59:38 -07:00
Patrick Buckley e136237b63 fix(providers): openai-compatible never consults the commercial table
Local-lane model ids are operator-chosen strings (vLLM
--served-model-name), so a prefix collision with a cloud model id
inherited that model's sampling and effort contract: a box named
o3-distill silently lost temperature support, and one named
gpt-5.5-my-finetune was sent gpt-5.5's snapped reasoning_effort values
it never declared. Both surfaces of the lane now return plain defaults
(OPENAI_COMPAT_DEFAULT in _openai_common): the chat class directly, and
the responses pin via a compat-mode OpenAIResponsesProvider mirroring
AnthropicProvider(compat=True). Everything beyond the defaults is
declared by the operator on the model definition, matching the
anthropic-compatible lane and lookup_model_capabilities' documented
'no static table for local models' contract. The commercial openai
lane (Responses-only) is untouched.

Pre-split tests that reached commercial rows through the chat-class
OpenAIProvider alias now source them from lookup_openai_capabilities;
their subject (registry rows + shared gating helpers) is unchanged.
2026-07-05 01:59:38 -07:00
Patrick Buckley 41e9907803 fix(console): available-models rows always carry effort_ladder
The except path for a malformed capabilities column appended the row
without the key, so clients had to null-check a field the happy path
guarantees. Initialize each entry with an empty ladder and let the try
block overwrite it — the response schema is stable per row.
2026-07-05 01:59:38 -07:00
Patrick Buckley 7c34d859b4 test(sdk): update stale send() vitest expectations to the path-keyed contract
Three server.test.ts / server-attachments.test.ts cases still asserted
the pre-verb-lift send shape (POST /v1/api/send with ws_id in the body).
The server route has been POST /v1/api/workstreams/{ws_id}/send (ws_id
in the path, {message} body) since that lift, and the SDK send() was
updated with it — only these expectations rotted. They fail on main
too; unrelated to approvals, folded in here to leave the TS SDK suite
green. Test-only, no SDK source change.
2026-07-05 01:57:54 -07:00
Patrick Buckley 16ee4e12ef fix(sdk): mark 1.7-added approval-cycle fields optional in the TS SDK
ApproveRequestEvent.cycle_id and ApprovalResolvedEvent.cycle_id /
call_ids were typed required, but they are 1.7 additions: a pre-1.7
server omits them on the wire, so a current SDK talking to an older
node sees undefined. The UI and channel adapters already keep the
legacy no-selector fallback for exactly that case. Mark them optional
so consumer code can't assume a string that may be absent — matching
the Python SDK, whose dataclasses default all three.
2026-07-05 01:57:54 -07:00
Patrick Buckley 976c07d047 fix(ui): make the App the sole owner of sendBtn.disabled
The interactive composer had two uncoordinated writers of
sendBtn.disabled: _syncApprovalState wrote `pendingApproval || busy`
directly, while Composer.setBusy wrote it too via _reconcileDisabled.
In queueWhileBusy mode the composer's write re-enables send, so a
state_change to "attention" (exactly the pending-approval state)
firing after an approve_request card rendered raced the approval
disable back off. The `|| busy` term also defeated the "Queue
message…" affordance whenever _syncApprovalState ran mid-turn.

Opt the composer into externalDisable (it keeps rotating the
Send/Queue label + placeholder + stop button, but no longer writes
the flag) and route every axis — live approval cycle, cross-user send
gate, busy — through one App reconciler, _reconcileSendDisabled. Busy
is intentionally not a disable axis here: queueWhileBusy keeps Send
clickable as "Queue" while the agent runs.

Pre-existing on main (the old scattered direct writes had the same
collision); surfaced by the concurrent-cycle review.
2026-07-05 01:57:54 -07:00
Patrick Buckley 8da5dc3f5a docs(api): regenerate OpenAPI specs for the details-list contract
The checked-in openapi-server.json / openapi-console.json still
described the removed singular pending_approval_detail field; re-run
generate-types.py so the reference specs carry the
pending_approval_details list + cycle_id. Retire two docstring
references to the deleted singular serializer and one stale comment.
2026-07-05 01:57:54 -07:00
Patrick Buckley 7f0e0406b3 test(approvals): concurrency matrix + suite migration to the cycle model
New regression matrix for the release blockers: cross-approval
independence, lost-wakeup at gate entry, FIFO selector-less
resolution, resolve-all sweep, double-resolution no-op, cards/legacy
view tracking, and the generation-exactness set — stale delivery
rejection, Smart-Approvals origin check, purge keep_origin, the
purge-to-register window eviction, late cross-generation "superseded"
stamping, concurrent smart+human gates, and the pre-delivered-verdict
fast path. Plus sub-agent judge wiring (agent_gate off the main
slot, close() firing all generations) and endpoint tests for cycle
pinning and the Approve+Always race guard.

Gate threads run under one shared mock-patch harness — mock.patch
start/stop of the same target from concurrent threads corrupts the
patcher's restore stack — with a sweep-until-dead teardown so the
conftest leak guard can't trip. Existing suites migrate off the
singleton fields to cycle assertions and the
pending_approval_details wire shape.
2026-07-05 01:57:54 -07:00
Patrick Buckley 6c94514106 feat(ui): concurrent approval cards in the interactive and coordinator frontends
interactive.js tracks live cycles in a Map: per-cycle action buttons
and feedback, per-cycle optimistic clears and resolved-status pills,
keyboard routed to the oldest (or the focused) cycle, announce-shell
dedupe, and a composer that stays disabled while ANY cycle is live.
Verdict glow is scored per batch — a sibling's verdict neither
recolors the oldest card nor leaves its own card stale.

coordinator.js renders one approval block per pending_approval_details
entry, posts child approve/deny with the block's cycle_id, clears
exactly the resolved cycle on approval_resolved (legacy events without
one clear all), and replays every entry from snapshots.
2026-07-05 01:57:54 -07:00
Patrick Buckley 0d0fe8dd71 feat(channels): cycle-keyed approval tracking in Slack and Discord
Track posted approval prompts by (ws_id, cycle_id) — one message per
concurrent cycle — so parallel task agents' prompts resolve
independently. Buttons carry the cycle in their value (Slack) /
footer (Discord); intent verdicts route onto the owning cycle's
message by call_id membership.

The exact-key-then-legacy-fallback lookup (an empty cycle_id from a
pre-multi-cycle server resolves the workstream's single tracked
entry) and the all-cycles sweep are centralised as shared _routing
helpers so the fallback semantics can't drift between adapters.
2026-07-05 01:57:54 -07:00
Patrick Buckley 18c3301428 feat(api): cycle-routed approval resolution across server, console, schemas, SDKs
POST /approve accepts cycle_id / call_id selectors and 409s on stale
selectors with the current cycle's ids so clients re-render instead of
silently resolving an unrelated batch. Selector-less bodies pin the
resolve to the cycle the lookup returned (not "whichever is oldest by
the time the resolve runs"), and Approve+Always names apply only after
the pinned cycle actually resolved — the auto-approve whitelist can no
longer describe a different batch than the one that resolved.

approve_request carries cycle_id; approval_resolved carries cycle_id +
call_ids; SSE reconnect replays every live cycle's card. The console
collector, coordinator UI fan-out, and both SDKs (Python + TS) thread
the cycle correlation through.

BREAKING (1.7): the singular pending_approval_detail field is removed
from dashboard rows, workstream detail, and node snapshots — replaced
by the pending_approval_details list (one entry per live cycle, each
carrying its cycle_id). stable/1.6 keeps the old shape.
2026-07-05 01:57:54 -07:00
Patrick Buckley 185dcc2960 feat(judge): sub-agent gates run the intent pipeline as their own generation
task_agent tool calls reached the approval gate judge-blind: no
heuristic verdict on the card, no LLM verdict, no audit row, and Smart
Approvals could never clear them. Run _evaluate_intent on the
sub-agent gate with the sub-agent's own trajectory as judge context —
its task prompt is the delegation contract the operator approved, so
"does this call serve the task" is the right local alignment question.

Sub-agent spawns are agent_gate generations: they never touch the main
loop's supersede slot (parallel siblings would make each other's
verdicts look stale), staleness is enforced per-cycle by the UI's
generation checks instead. Every generation registers in
_judge_cancel_events — kept exact by the judge's new done_callback —
so close() aborts all in-flight daemons; judge.cancel_on_approval
fires per-gate exactly like the main loop. CLI and eval UIs accept
the judge_event delivery kwarg.
2026-07-05 01:57:54 -07:00
Patrick Buckley 0fe8e4106f feat(approvals): concurrent approval-cycle registry in SessionUIBase
The approval pipeline was a per-UI singleton (one card, one Event, one
result slot) multiplexed by N concurrent gates. With parallel task
agents that meant one click could resolve every parked batch with the
same verdict, a sibling's gate entry could eat a just-fired resolution
(3600s "stuck dialog" hang), and Approve+Always could whitelist a
different batch than the one on screen.

Replace the singleton with a registry of ApprovalCycle objects keyed
by cycle_id: per-cycle events/results/decisions/verdict parking,
oldest-first selector-less resolution, guarded double-resolution, a
resolve_all_approvals sweep for cancel/close/worker-recovery, and a
maintained oldest-cycle view in the legacy _pending_approval slot for
boolean-ish consumers.

Verdict bookkeeping is generation-exact end to end: the entry purge
spares verdicts the entering batch's own judge spawn already delivered
(a fast judge no longer stalls the Smart-Approvals wait to its full
budget), registration evicts stale-generation arrivals that land in
the purge-to-register window, recent decisions are generation-tagged
so a stale generation's late verdict stamps "superseded" instead of
stealing a reused call_id's decision, and Smart-Approvals
qualification identity-checks the delivering generation.
2026-07-05 01:57:54 -07:00
Patrick Buckley fd65a490dc chore: keep design docs local-only 2026-07-05 01:57:54 -07:00
Patrick Buckley 3607517814 fix(providers): registry effort truth — o-series/gpt-5.5/codex-max/sonnet-5; forward declared none
Capability-registry corrections verified against the official OpenAI
reasoning guide, the Azure reasoning-models matrix (2026-06 revision),
and the Anthropic models-overview/effort/migration pages (2026-07):

OpenAI (vocabulary confirmed none/minimal/low/medium/high/xhigh — no
"max" level exists; knob max rides the xhigh ceiling via the ordinal
snap):
- o1/o3/o3-mini/o3-pro/o4-mini declare low/medium/high (every o-series
  model except o1-mini) — without declared values the session knob was
  silently dropped for these models. o1-mini stays effort-free.
- gpt-5.5 default corrected none -> medium (5.5 reasons by default,
  unlike 5.1-5.4).
- gpt-5.1-codex-max gets an explicit row: it prefix-matched the
  gpt-5.1 row (no xhigh), capping the knob's xhigh at high on the one
  model xhigh was introduced for.

Anthropic (effort-page matrix):
- claude-sonnet-5 row added — it previously fell through to
  _ANTHROPIC_DEFAULT (manual budgets, 200k ctx, no effort), all wrong:
  adaptive-by-default thinking (manual budgets are a 400), sampling
  params rejected, 1M ctx / 128k out, effort low..max incl. xhigh.
- claude-sonnet-4-6 gains its documented "max" effort level (knob
  xhigh now rides max, not high) and the stale 64k max_output becomes
  the documented 128k.
- fable-5 / opus-4-8 / opus-4-7 / opus-4-6 / opus-4-5 rows verified
  correct as declared.

Knob semantics completed: resolve_reasoning_effort now forwards the
knob's "none" position verbatim when the model DECLARES an explicit
none level (gpt-5.1+, grok-4.3) — omitting the param there leaves a
reasoning-on server default (gpt-5.5: medium) in charge of a knob
that promises off. Models without a declared none still omit, and
none is never a snap target. Parity harness swaps its synthetic
openai shape for the real gpt-5.5 registry row.
2026-07-04 20:54:20 -07:00
Patrick Buckley a0e04a8588 fix(providers): effort snapping is ordinal — round up, cap at the ceiling
The knob domain grew xhigh/max after the snapping fallbacks were
written, which silently inverted their semantics: off-list meant
"unrecognized string" then, but now usually means "above the model's
ceiling", where falling back to the default tier is directionally
wrong (grok-4.3 at knob max got low; values low/medium/high at knob
xhigh got medium; Anthropic manual mode gave xhigh/max a 4096 budget
while high got 16384).

One rule everywhere now, via snap_reasoning_effort in _protocol:
exact match wins; otherwise the smallest declared level ranking at or
above the knob; above the ceiling, the ceiling. "none" is never a
snap target, and default_reasoning_effort only catches values the
ordinal snap cannot rank.

- resolve_reasoning_effort (flat chat / responses / validated
  effort_param lanes) snaps ordinally: xhigh over (low, medium, high)
  now sends high; xhigh over DeepSeek-style (high, max) sends max —
  matching DeepSeek's official xhigh-to-max aliasing, so a declared
  values list now reproduces that contract instead of defeating it.
- _map_reasoning_to_effort (native output_config) rounds up too:
  knob xhigh on Opus 4.6 (low, medium, high, max) rides max instead
  of silently dropping output_config.
- EFFORT_BUDGET_MAP is monotone across the whole knob domain:
  minimal/low 1024 (API floor), medium 4096, high 16384, xhigh 32768,
  max 65536. Unknown strings still fall to the 4096 default.

Google defaults are unaffected (ceiling and default coincide at
high); wire goldens unchanged. Parity harness caught the budget
clamp interacting with its own max_tokens during development —
capture budget raised above the largest manual budget.
2026-07-04 20:54:20 -07:00
Patrick Buckley ffe8214cfe test(providers): ladder-to-wire effort parity harness across all lanes
Proves the effort-ladder projection against the real request path
instead of against the mapping helpers it shares with it. For 22
(provider lane x capability shape) points — both Anthropic lanes,
openai-compatible on both API surfaces, openai, google (default and
template-override hybrid), xai (default and inert-override), and the
DeepSeek/qwen template contracts — every knob position is driven
through the actual provider create_streaming against a recording fake
client, and two invariants are asserted per shape:

1. each ladder token decodes to an expected effort wire subset
   (toggle / template effort / flat param / thinking budget /
   output_config) that must equal the captured kwargs exactly;
2. two knob positions carry equal tokens iff they produce identical
   effort-relevant wire payloads — the grouping promise the UI
   annotations lean on.

The RecordingClient SDK-seam stub moves from the wire-payload golden
harness into tests/_wire_capture.py so both suites capture at the same
seam. Verified the harness catches the bug class it was built for:
re-adding xai to _CHAT_LANES fails xai-template-override-inert.
2026-07-04 20:54:20 -07:00
Patrick Buckley 1f63f622c9 fix(providers): xai effort ladder is flat-only; share the suppression rule
Second external audit round on the ladder. Verified and fixed:

- xai was in _CHAT_LANES on the false premise that XAIProvider
  subclasses the chat provider. It subclasses OpenAIResponsesProvider,
  whose surface ignores extra_body entirely, so a thinking_mode /
  effort_param override never changes an xai request — but the ladder
  claimed a template toggle ("on+low") that does not exist on the
  wire. xai now projects through the flat channel only, like openai.
- The flat-param suppression rule (a declared effort_param claims the
  template channel) was encoded independently in
  apply_temperature_and_effort and the ladder. Extracted into
  flat_effort_suppressed() in _protocol so the request path and the
  projection cannot drift.
- admin_effort_ladder logs the swallowed resolver exception before
  returning 400 (a genuine bug would otherwise hide as a silent 400).
- list_available_models reads server_compat with .get() instead of
  destructively popping it out of the parsed capabilities dict.
- models_changed SSE now re-annotates the skill launch-config effort
  select after invalidating the models cache instead of leaving a
  stale ladder until the next keystroke.
- Capabilities JSON textarea placeholder hints the two effort fields
  that have no structured control (reasoning_effort_values,
  default_reasoning_effort).
2026-07-04 20:54:20 -07:00
Patrick Buckley f4701bf0f9 test(golden): re-baseline Google wire payloads for the effort knob
The Gemini effort fix (ee9e9c1f) adds a flat reasoning_effort to every
Google chat-completions request at the session default knob — the
golden fixtures now carry it. Only the eight google__* goldens change
(one added key each); other providers' goldens are untouched — the
UPDATE_WIRE_GOLDENS pass also wanted to rewrite twelve passing goldens
with escape-format-only churn (raw em-dash vs \u2014), reverted to
keep the diff semantic.
2026-07-04 20:54:20 -07:00
Patrick Buckley 06cc184227 fix(console): address verified external-audit findings on the effort ladder
The one that mattered: /v1/api/models passed the capabilities column —
a JSON STRING (sa.Text) — straight into effort_ladder_for_model, whose
field filter calls .items() on it; the per-row guard swallowed the
AttributeError, so effort_ladder was silently absent from every row and
the sklc annotation could never fire. The endpoint now parses the JSON
and splits the namespaced server_compat exactly like the model_registry
loader, and a regression test seeds a string-capabilities row.

Projection fidelity: effort_ladder_for_model threads api_surface (the
responses surface ignores extra_body — flat-param-only ladder, matching
create_provider's request-time divergence); google/xai route through
the chat-lane projection they actually inherit (_finalize_extra_body +
flat param); the native-Anthropic branch reflects that output_config
gates on supports_effort alone, independent of thinking_mode; budget
clamping to per-request max_tokens is documented as out of scope.

Hardening and symmetry: admin endpoint 400s (not 500s) on non-dict JSON
bodies and gained HTTP tests; the admin edit-load strips effort_param
from the raw JSON only for the lanes whose save path re-adds it, so
non-compat rows can't silently lose a stored key; the empty-model early
return bumps the ladder sequence so stale in-flight responses can't
re-annotate; alias labels only reference positions the target select
actually offers (the skill shelf omits none/minimal); the sklc models
cache no longer pins a rejected promise and is invalidated on the
models_changed event; debounces unified at 500ms;
merge_reasoning_template_kwargs now always returns a fresh dict for
non-empty input; the shared budget constants are public.
2026-07-04 20:54:20 -07:00
Patrick Buckley 59a527f2f2 feat(console): surface each model's effective effort ladder
Seven knob positions render as seven behaviors in the UI, but the real
ladder depends on the lane and the model: qwen3.6 has two (off/on),
DeepSeek-V4 three, Claude 4.6 five. Operators had no way to see which
positions alias — the confusion class behind silently-equal effort
levels.

providers/effort_ladder.py projects the knob domain through the same
mapping functions the providers use at request time (resolve_reasoning_
effort, reasoning_template_kwargs, the manual budget map — hoisted to a
shared constant so the projection can't drift), yielding
{value, effective} rows where equal tokens promise identical requests.
/v1/api/models rows now carry the ladder (guarded per row), and
POST /v1/api/admin/models/effort-ladder computes it for the admin
modal's unsaved edits.

The admin per-model effort select and the skill launch-config effort
select annotate aliased positions ("Max (= high)", "None (model
default)") with a sends-tooltip; annotations refresh as thinking-mode /
effort-param / capabilities fields change. The ladder describes what
Turnstone sends — server-side templates may alias further (DeepSeek-V4
folds low/medium into its default high tier).
2026-07-04 20:54:20 -07:00
Patrick Buckley d7941c88be fix(providers): thread the session effort knob to Gemini
_GOOGLE_DEFAULT declared no reasoning_effort_values, so
resolve_reasoning_effort returned None and the session effort knob was
silently dropped for every Gemini model — the same bug class this
branch fixed on the local lanes. Gemini's OpenAI-compat surface
documents a flat reasoning_effort (2.5: thinking_budget mapping; 3.x:
thinking_level), so declaring values lights up the inherited
chat-completions path.

Values are the safe cross-model set (minimal/low/medium/high): "none"
is excluded because 2.5 Pro and the 3.x family reject disabling
thinking — and the resolver never forwards the knob's none anyway (the
param is omitted, server default applies). Off-list xhigh/max snap to
the declared default high. Encoded from the official compatibility
docs per the static-caps pattern; not live-verified.
2026-07-04 20:54:20 -07:00
Patrick Buckley c64dc16319 feat(console): Always-on thinking-mode option in the model form
Post effort-knob rework, "Enabled" (manual) means knob-controlled —
effort none turns thinking off. Operators who want the pre-#771
always-on behavior (knob never disables) previously had to hand-write
thinking_mode "adaptive" into the raw capabilities JSON. The dropdown
now offers all three representable modes — None / Effort-knob
controlled / Always on — and the edit-load lift captures adaptive
instead of relegating it to raw JSON.
2026-07-04 20:54:20 -07:00
Patrick Buckley d564cee43d docs(providers): ground the effort_param values caution in official template contracts
Cross-checked online: Qwen3.6's template documents enable_thinking +
preserve_thinking only — no effort parameter exists (vLLM's flat
reasoning_effort convenience boolean-maps to the same toggle).
DeepSeek-V4 officially accepts reasoning_effort high/max with Think
High as the default thinking tier and low/medium→high, xhigh→max
aliasing — so freeform effort_param passthrough matches the contract
exactly, and a declared values list omitting xhigh/max would make
Think Max unreachable. Live probes on both boxes agree with the
official contracts once the default-tier framing is applied.
2026-07-04 20:54:20 -07:00
Patrick Buckley 2cf23b6fe2 fix(providers): address high-effort review of the reasoning-knob branch
Verified findings applied:
- adaptive thinking_mode never knob-disables: the shared mapping now
  sends the toggle unconditionally true for adaptive (the native
  adaptive branch ignores the knob's none), while manual keeps the
  knob-driven contract. Restores the invariant the deleted chat-lane
  code upheld.
- a set effort_param suppresses the flat top-level reasoning_effort on
  the chat lane: the template channel replaces it — double-sending
  could 400 on schema-strict servers and disagree with operator pins.
- admin edit-save no longer drops a stored thinking_param when the
  thinking-mode dropdown is empty: the raw-JSON strip now only fires
  when a mode value actually round-trips through the dropdown.
- effort_param persistence gated on the local-server lanes so a value
  lingering across a provider switch never lands on commercial rows.
- three stale _compat_extra_params references renamed to
  merge_reasoning_template_kwargs.

Documented dispositions (no code change): the knob-none-disables flip
on upgrade is intentional and now carries an upgrade note; gateways
fronting real Claude belong on provider=anthropic with a custom
base_url (the compat lane is vLLM-schema-only); nonstandard
thinking_mode strings staying inert is the intended allowlist
contract. The real anthropic provider is unaffected throughout —
official Claude models keep native thinking/output_config.
2026-07-04 20:54:20 -07:00
Patrick Buckley 68b22adfa3 feat(providers): share the effort-knob→chat_template_kwargs mapping with the openai-compatible lane
Hoist the compat-lane injection into _protocol.merge_reasoning_template_kwargs
(next to ModelCapabilities — one implementation for both local-server lanes)
and retire OpenAIChatCompletionsProvider._apply_thinking_mode in its favor:
_finalize_extra_body now receives the session effort knob, so thinking_mode
manual/adaptive maps knob "none" to an explicit thinking_param false
(previously the toggle was unconditionally true) and caps.effort_param
carries the graded effort key on chat completions too. Operator
server_compat pins still win; the Responses surface is untouched (native
reasoning handles effort itself).

The admin Models form grows an "Effort param" field that round-trips like
thinking_param: lifted out of the raw capabilities JSON on edit-load,
re-added on save, cleared by emptying the field.

Verified live against qwen3.6-27b /v1/chat/completions: knob medium streams
reasoning_content, knob none suppresses it.
2026-07-04 20:54:20 -07:00
Patrick Buckley 9289693730 fix(providers): drive reasoning via chat_template_kwargs on the anthropic-compatible lane
The compat lane sent no reasoning control at all: vLLM's /v1/messages
has no thinking request field, thinking_mode stayed "none", and the
session effort knob was silently dropped. The reasoning levers live in
the chat template, so fold them into extra_body chat_template_kwargs
(_compat_extra_params): thinking_mode manual/adaptive maps the knob
onto caps.thinking_param (effort "none" = off, mirroring the native
manual-mode contract), and caps.effort_param (new ModelCapabilities
field) carries a graded effort value for gpt-oss-style templates,
validated against reasoning_effort_values when declared. Operator
server_compat entries win on key collision; native thinking params,
temperature forcing, and output_config never fire on compat.

resolve_reasoning_effort moves from _openai_common to _protocol next to
ModelCapabilities — importing it into _anthropic would otherwise cross
provider families.

The admin Models form now shows and round-trips the thinking-mode
dropdown for this lane; the #661 hide was premised on thinking_mode
being inert here, which this change inverts.

Verified live against qwen3.6-27b on vLLM /v1/messages: knob medium
streams a thinking block, knob none suppresses it, an operator pin
beats the knob.
2026-07-04 20:54:20 -07:00
metaclassing deff44bcea Addendum to Entra ID's... proclivities (#772)
* OIDC entra capture

* copilot being nitpicky

---------

Co-authored-by: pow3rtool <root@pow3rtools>
2026-07-04 16:48:52 -07:00
Patrick Buckley 217d3a3a9b feat(mcp): autonomous reconnect + liveness for static MCP servers (#768)
* feat(mcp): autonomous reconnect + liveness for static MCP servers

Static (non-oauth_user) MCP servers had no autonomous reconnect. Every reconnect
path was lazy — a tool dispatch (_cb_auto_reconnect), an operator refresh, or a
config edit — and the MCP SDK's own reconnect is a bounded 2-attempt burst on the
streamable-http GET stream only (verified: mcp 1.28.1), with no backoff and
nothing for the other transports. So a static server that went down and came back
while nobody was dispatching to it stayed disconnected until a dispatch or a
manual reconnect. Worse, a session whose transport dies while idle survives as a
non-None ClientSession with closed streams — nothing evicts it, so even a later
dispatch may not notice until it fails.

Add a static-server health loop on the mcp-loop (started in _connect_all; config
``static_health_check_seconds`` default 30, <= 0 disables):

- Reconnect: a disconnected server (session is None) is reconnected on a capped,
  jittered, FOREVER backoff (full jitter, base 1s, cap 60s, no attempt limit) —
  a server that returns after a long outage reconnects within ~a minute, and a
  permanently-misconfigured one costs at most one attempt per cap. The health
  loop owns this clock; the circuit breaker stays the DISPATCH fail-fast gate (a
  tool call to a down server errors immediately rather than blocking on the
  retry), and the loop keeps breaker state in sync so an open breaker closes on
  reconnect.
- Liveness: a connected server is pinged (send_ping) each cadence; a dead-but-
  idle one — which nothing else would notice — is evicted so the next tick
  reconnects it. This is the core of the "never reconnects" failure.

Serialize _connect_one per server behind a per-name lock (split into a thin
wrapper + _connect_one_locked): the health loop, a dispatch's _cb_auto_reconnect,
and an operator refresh could otherwise interleave teardown/rebuild on the shared
StaticServerState and corrupt it — a latent pre-existing race this also closes.
The body is unchanged (only relocated), so the delicate anyio / wait_for connect
logic is untouched.

Out of scope (follow-up): silent GET-stream / notification death — the SDK stops
the notification stream after 2 attempts while the request path stays alive, so
send_ping is blind to it; the fix is bounded session recycling, which needs a
static in-flight guard first (only PoolEntryState tracks in_flight today).

Tests: backoff bounds (capped / jittered / forever, no overflow), reconnect
success resets backoff + closes breaker, reconnect failure retries forever,
in-flight skip, per-name serialization (no overlap), ping keeps healthy / evicts
dead / evicts on timeout, tick skips oauth_user, connect_all start + disable
gating, clean cancel.

* fix(mcp): harden static-server health loop (review findings)

A max-effort review found 13 concurrency/correctness defects, all from the loop
mutating shared StaticServerState without the interlocks the pool path carries.
Fix all 13:

- In-flight interlock: add StaticServerState.in_flight (parity with
  PoolEntryState); _static_session_op increments/decrements around the static
  call_tool/read_resource/get_prompt session ops; the ping skips and never
  evicts a busy server, so a long tool call can't be torn down mid-flight.
- Dead-transport gating: the ping evicts + trips the breaker only on
  _is_dead_transport(exc); an McpError, httpx.PoolTimeout, or plain ping timeout
  is "slow, not dead" and only reschedules (matching the dispatch path).
- Session-identity: only evict the exact session that was pinged.
- asyncio.timeout (invariant-18) not wait_for for the ping; 5s->30s; the
  timeout-scoped cancel is distinguished from an external shutdown cancel
  (which still propagates) via .expired().
- Bounded reconnect: wrap _connect_one in asyncio.timeout so a server that
  handshakes then stalls list_tools can't wedge the loop or hold the per-name
  lock forever (connect internals untouched).
- Concurrent tick under asyncio.gather with a freshly-read clock for the sleep.
- Cross-path coordination: reconnect_sync/remove_server_sync take the per-name
  lock across teardown+rebuild (calling _connect_one_locked directly);
  _cb_auto_reconnect reuses a health-established session instead of racing a
  redundant reconnect and no longer trips the breaker on lock contention.
- Backoff hygiene on recovery; skip '__' names; on health reconnect clear only
  the open-circuit deadline (not the failure count) so a connect-ok/calls-fail
  server still escalates to a trip.

Adds 14 tests and adjusts those that assumed the old behavior; suite 195->209.

* fix(mcp): unify static-server reconnect coordination (round-3 review)

A third review round + live testing found 8 issues on the health loop, five
sharing one root: reconnect logic was fragmented across five drivers, each
handling the lock / session-reuse / in_flight / config-recheck / breaker / clock
differently and incompletely. Introduce one primitive and route every
lazy/autonomous driver through it.

_ensure_static_connected(name, cfg) — the single lazy (re)connect path, all under
the per-name lock: config re-check (+ lock-identity re-check, closing the
remove->re-add race) so a removed server is never resurrected; reuse-if-live so a
queued/concurrent driver never tears down and rebuilds a live session (the
observed reconnect storm); in_flight guard so a reconnect can't tear down a
session with a call still in flight on the evicted stack; bounded connect; and the
circuit breaker owned in one place (clear the open-circuit deadline on success per
finding-13, record one failure on real connect failure). Returns the session on
success/reuse, None on a deliberate skip, raises on real failure. Routed through
it: the health loop, a dispatch's _cb_auto_reconnect, and _refresh_all; operator
reconnect_sync stays a deliberate force-rebuild.

Also: fresh-clock deadlines (the stale tick-start clock was landing deadlines in
the past and collapsing the backoff into an every-tick retry storm); loop-death
fix (the tick no longer re-raises a CancelledError found in the gather results —
per-server fallout, not shutdown; the loop returns only when Task.cancelling()
marks a genuine shutdown); dispatch breaker records no failure on a sync-boundary
reconnect timeout (lock contention is not a server failure; real outcomes recorded
once, inside the primitive). Cleanups: extract _teardown_static_session (was
copy-pasted 3x); share _capped_exponential between the breaker cooldown and the
reconnect backoff. Adds 17 tests; test_mcp_client 204->226.

* fix(mcp): coherent timeout hierarchy + round-4 review fixes

A fourth review round on the unified reconnect coordination found 5 correctness
regressions + 1 cleanup, five sharing one root: the inner reconnect attempt bound
(45s) was LONGER than every caller wait (dispatch 30s, remove 15s, reconnect 30s),
so a caller cancelling mid-attempt delivered a bare CancelledError that slipped
past the primitive's `except Exception`.

- Coherent timeout hierarchy: add _STATIC_RECONNECT_CALLER_TIMEOUT_S (> the inner
  attempt bound) for the dispatch + operator waits, so the inner asyncio.timeout
  always fires first — a clean TimeoutError the primitive converts, cleans up, and
  records on the breaker — instead of a caller cancelling a live attempt. Fixes
  [0] (half-discovered session left installed, served with a stale catalog) and
  [1] (breaker never trips via dispatch).
- Primitive cancel-safe (belt-and-suspenders): its handler is now
  `except BaseException`, so even a bare CancelledError drops the partial session
  and records the failed attempt before re-raising.
- Operator waits: reconnect_sync / remove_server_sync default timeouts raised above
  the reconnect bound; remove_server_sync now CANCELS the pending _remove on
  timeout (so it can't later pop a re-added entry and corrupt state) and reports
  failure instead of a false 'removed' ([2], [4]).
- in_flight defer is gated on defer_if_busy: autonomous callers (health loop,
  _refresh_all) defer, but a DISPATCH reconnects rather than hard-fail a reachable
  server with an in-flight sibling ([3]).
- Cleanup: extract _schedule_next_ping (was a copy-pasted triplet in 3 branches) [5].

Adds 6 tests; the lock-contention test now shadows the caller-timeout constant so
it runs in ~1s instead of the full wait.

* fix(mcp): address PR review feedback on static reconnect

- _ensure_static_connected: skip breaker record on CancelledError
  (cancel proves nothing about the server; aligns docstring with impl)
- reconnect_sync: add asyncio.timeout wrapper so discovery-phase
  stalls get a clean TimeoutError inside the lock
- reconnect_sync: change except Exception to except BaseException
  so CancelledError from future.cancel() triggers catalog cleanup
- reconnect_sync: null state.session on failure so a tool-less
  session isn't mistaken for a live one
- _static_reconnect_one: use fresh monotonic clock for backoff
  gate instead of stale tick-start snapshot; remove dead now param
- _static_health_tick: correct docstring (0.5s clamp prevents
  busy-spin, not 'no sleep through short backoff')
- Test: new test for reconnect_sync timeout + catalog cleanup
- Test: update cancelled-attempt test for new CancelledError semantics
- Test: narrow except BaseException to except Exception + type hints
- Test: fix typo 'Understone' -> 'Turnstone'
- Test: remove unused stale_now variable; update mock signatures

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-04 07:40:48 -07:00
Stefano Maffeis bcf509a440 Drop dead last_ws_id/last_tool_call_id columns from mcp_pending_consent
Migration 054 added these columns but they were never populated (the sole
writer hardcodes None) and never read by any query, dashboard, or API.
Drop them so the schema matches reality.

Closes #769

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-04 03:27:55 -07:00
Patrick Buckley 10f726f83d docs(mcp): correct _write_pending_consent last_ws_id/last_tool_call_id note
The docstring claimed the dispatch path plumbs a triggering ws / tool-call id
through a thin wrapper. It does not: _record_pending_consent_best_effort neither
receives nor forwards any id (the mcp dispatch layer never has one), the
proactive sweep has no dispatch, and nothing reads last_ws_id / last_tool_call_id
— they are unpopulated schema columns from migration 054. State that plainly so
the comment doesn't imply data is captured when it isn't (Copilot review nit).
2026-07-03 22:11:15 -07:00
Patrick Buckley acc262c405 feat(mcp): proactively keep consented OAuth (OBO) tokens fresh for unattended work
Per-user OAuth (auth_type=oauth_user) token refresh is entirely lazy: a token
is refreshed only when a tool is dispatched or a session binds the acting user,
and a dead refresh token is discovered only when a dispatch fails. That assumes
a human is driving the session, which breaks for autonomous / scheduled work
acting on behalf of an absent user — the token may be expired (latency), in a
transient-failure cooldown (unavailable), or the grant may be dead with nobody
present to re-consent. The only periodic MCP-loop task, idle eviction, actively
tears OBO connections down; nothing keeps tokens warm.

Add a background token-freshness sweep that keeps every consented oauth_user
grant hot WITHOUT keeping connections warm and WITHOUT mutating consent state on
a timer.

Sweep (_user_token_sweep_loop, default 240s):
- Enumerates consented (user, server) grants from the token store and runs the
  canonical refresh path for each. Strictly oauth_user-scoped: gates on
  _oauth_user_server_names and drives off mcp_user_tokens rows, so a static /
  no-auth server — which has neither — is structurally invisible (no DB scan, no
  authorization-server round-trip, no MCP-server call). Never connects to the
  MCP server; connections stay lazy.
- Observe-only: passes revoke_on_failure=False (new parameter on
  get_user_access_token_classified) so a timer NEVER deletes a token, emits
  token_revoked, or mutates the shared ambiguous-streak / cooldown. A dead grant
  is only surfaced (proactive dashboard pending-consent badge); the
  authoritative revoke stays on the lazy-dispatch path where a real user action
  justifies it. Because the row survives, a spurious server-wide invalid_grant
  (an AS maintenance window) self-heals — the badge is dropped on the tick the
  grant works again.
- Keepalive: force-refreshes a grant whose refresh token has sat un-exercised
  past user_token_refresh_keepalive_seconds (default 1800s) even while the
  access token is still fresh, so a provider that ages out idle refresh tokens
  can't expire one between a user's real sessions.
- Surfaces dead grants once per transition, pinning the pair only after a
  durable badge write so a failed persist retries rather than being lost. First
  sweep runs after a short startup grace so a restart surfaces a downed grant
  within seconds, not a full cadence later.

Cadence <= 0 disables the sweep; a positive value is floored (30s) so a
misconfigured tiny cadence can't turn the loop into a busy-loop. Reuses the
per-key refresh lock, so a keepalive force cannot double-refresh against a
concurrent dispatch.

Storage: add list_mcp_user_token_reconcile_targets() returning
(user_id, server_name, COALESCE(last_refreshed, created)) — expiry-unfiltered,
no ciphertext projected — on the protocol, sqlite, and postgres backends.

Tests: the sweep's no-auth invisibility (zero DB / AS calls with no oauth_user
server), observe-only non-destruction (token kept and shared streak untouched on
a background permanent / ambiguous failure), keepalive gating, badge
persist-then-pin retry, self-heal on recovery, cadence clamp / disable, and the
storage enumerator.
2026-07-03 22:11:15 -07:00
Patrick Buckley 62034378c6 fix(skills): address Copilot review — task_agent doc refs + gate fail-closed on missing storage
- task_agent.json referenced a non-existent `skill(action='search', query=...)`
  in two spots. The discovery tool is `skills` and the action is `find`
  (`search` is an activation value, not an action), so the guidance would
  mislead the model. Corrected both to `skills(action='find', query='...')`,
  matching the tool's own error strings.
- _high_risk_skill_denied returned "" (allow) when get_storage() is None — an
  asymmetry with the fail-closed lookup-exception path added earlier. A risk
  gate that can't verify the tier must DENY, not wave the skill through, so
  storage-unavailable (None) now denies too; both paths share one denial.
2026-07-03 19:16:36 -07:00
Patrick Buckley bcb8c5ab88 fix(skills): harden task_agent / persona / skill activation from whole-PR review
Two independent multi-agent reviews of the branch (high, then max effort) found
authority-confinement and robustness defects the per-step reviews could not
see. This commit addresses every confirmed finding. task_agent turned out to be
the surface that lagged its siblings on nearly every axis.

Risk gate (most severe):
- task_agent(skill=...) never enforced the high/critical-risk PRINCIPAL-load-
  only gate that skills(load) / spawn_workstream / spawn_batch enforce, so a
  model could route around it by delegating activation to a sub-agent. Enforce
  it inline in _prepare_task on the row already fetched (no re-query, no drift
  between get_skill_by_name and get_prompt_template_by_name).
- _high_risk_skill_denied now fails CLOSED on a storage fault: deny, never wave
  the skill through. Denying (not returning "") also keeps spawn_batch's per-row
  partial-success intact under a transient blip.
- (first round) extracted _high_risk_skill_denied onto spawn_workstream /
  spawn_batch, closing the coordinator-side bypass.

Persona confinement (Principle 7 attenuation on the task_agent edge):
- A restrictive persona now attenuates the sub-agent's TOOLS, not just its
  identity text — the tool lever is frozen into the item and filtered before
  _run_agent.
- Honor ALL FOUR persona levers on the sub-agent, not two: a child persona's
  mcp-off and memory-off levers now drop MCP tools (mcp__* + read_resource /
  use_prompt) and the memory tool, matching a main session under the persona.
- Cap the sub-agent by the PARENT session's own persona grant too, so a
  restricted principal cannot escalate authority by spawning.
- Add persona to the task_agent judge/audit func_args projection (policy +
  audit parity with spawn).
- Persona-resolution failures defer to a clean tool error (try/except mirroring
  _validate_child_persona) instead of an opaque "internal error".

Substitution / capability:
- substitute_args=False for capability contexts (defaults, task_agent) so a
  literal $ARGUMENTS / $N in a body is preserved, not blanked; env vars still
  resolve. The literal-$ARGUMENTS scan is deferred behind that guard (skipped on
  every capability render).
- Drop the CLAUDE_SKILL_DIR alias (canonical TURNSTONE_SKILL_DIR only). That
  name also lives in bash, where turnstone-as-a-node-inside-Claude-Code must not
  shadow the host's value; claiming it in the prompt but deferring in bash
  diverged the two surfaces (a review finding). turnstone now claims it in
  neither surface. The CLAUDE_SESSION_ID / CLAUDE_EFFORT prompt aliases stay
  (pure prompt values, no bash-namespace collision).

Skills-as-context:
- DEFAULT (always-on) skills stay in the identity system message — the standing
  baseline, never a mid-session cache-bust; only a NAMED applied skill moves to
  the user-role capability message. This shrinks the pending model-adherence
  eval surface to the named-skill move alone.

Cleanups: consolidate a duplicated rationale comment; correct the now-stale
"task agents are not persona-filtered" note.

PRE-MERGE GATE unchanged: the §7 Q1 model-adherence eval (named-skill move,
this branch vs main) is not runnable in-tree and must clear before merge.
2026-07-03 19:16:36 -07:00
Patrick Buckley fec5067fcd feat(skills): gate model-initiated load of high/critical-risk skills
A skill can auto-fire tools (auto_approve + allowed_tools) once loaded, so
letting the model activate a risky skill through skills(action='load') is an
injection-steerable lane that widens authority behind a rubber-stampable
approval. Deny it: high/critical-risk skills are now PRINCIPAL-load-only --
the model gets a clean error pointing the user at /skill, and the operator
loads such skills explicitly (handle_command /skill and cli --skill call
set_skill directly and bypass this gate).

The scanner-computed risk_level is the gate signal -- it already escalates for
the auto_approve + allowed_tools authority the create path warns about -- so no
new column or migration is needed. The check can only DENY, never widen, so it
is safe by construction (HYPOTHESIS.md Principle 7 / design section 5.5).

Remaining step-5 follow-ups, out of scope here: persisting the literal
disable-model-invocation frontmatter field for arbitrary author-marked skills
(needs a column) and deprecating the vestigial variables mechanism.
2026-07-03 19:16:36 -07:00
Patrick Buckley b0ed67aa60 refactor(skills): move applied-skill body out of the identity system message
Step 3 of the skill/persona split: an applied skill (including default skills)
is CAPABILITY context, so its body no longer sits in the identity system
message. It rides its own message (user role) after the identity block, with a
short intro naming the active skill. The <available-skills> discovery catalog
stays in the system message.

Two consequences:
- The cached identity prefix (persona BASE + ENV + POLICIES + catalogs) stays
  stable across skills(load): loading/clearing a skill changes only the
  trailing capability message, not the identity block.
- The task_agent base (_agent_system_messages) is snapshotted BEFORE the skill
  block, so a parent's applied skill no longer leaks into the sub-agent prefix
  (the sub-agent supplies its own persona identity and skill via _exec_task).

PRE-MERGE GATE: the design gates this on a model-adherence eval (this branch vs
main) verifying the model follows a skill as well from a context message as it
did from the system message (design section 7 Q1; ASSUMED-neutral, UNVERIFIED).
That eval is not runnable in-tree and MUST clear before this branch merges.
Mechanical structure is pinned by TestSkillContextPlacement.

Deferred follow-up: sub-agent (task_agent) skill-resource materialization, so
${TURNSTONE_SKILL_DIR} stays literal on that path (unchanged since step 1).

Test helpers (_sys_content) now read the full prompt prefix (identity + skill
context) so placement-agnostic assertions keep working.
2026-07-03 19:16:36 -07:00
Patrick Buckley c023272b16 refactor(skills): task_agent identity from persona, skill demoted to capability
Before, a task_agent's system identity WAS its skill (skill body concatenated
into the sub-agent's system message), and #683 deliberately gave task_agent no
persona. Now that personas are first-class on every creation/spawn path, make
task_agent consistent: identity comes from a persona, the skill is capability.

- task_agent gains persona= (validated at prep against the interactive kind,
  the general-purpose personas a worker can adopt). The resolved base prompt
  is frozen into the approval item; _exec_task never re-reads storage.
- Default identity (no persona=) stays _TASK_DEFAULT_IDENTITY. The one-shot,
  tool-over-narration operating guidance always layers on top.
- skill= is now CAPABILITY: rendered through the shared pipeline (step 1) and
  delivered as a distinct user-role context turn ahead of the task, never
  fused into the identity. Consecutive user turns coalesce at the provider
  boundary (Anthropic _merge_consecutive), so this is wire-safe.

Updates the task_agent tool schema (persona param; skill reframed as
capability) and flips the persona guard test (task_agent HAS a persona param
now). Sub-agent skill-resource materialization and the interactive
skills->context move remain follow-ups.
2026-07-03 19:16:36 -07:00
Patrick Buckley 3568a6db50 refactor(skills): unify skill-body substitution across invocation contexts
Skill-body placeholder substitution diverged by invocation context:
interactive load, default skills, and spawn-child ran the full
render + spec-substitute, while task_agent (_exec_task) ran
_render_template only -- so $ARGUMENTS and ${...} env placeholders
rendered literally on that one path.

Introduce _render_skill_body as the single render+substitute path and
route interactive load, defaults, and task_agent through it, so a skill
reading ${TURNSTONE_EFFORT} or $ARGUMENTS resolves identically wherever
it runs. A sub-agent has no invocation args, so bare $ARGUMENTS and the
positional $N / $ARGUMENTS[N] forms resolve to empty there -- matching
the defaults and spawn-child paths, not the old verbatim passthrough.

- Add ${TURNSTONE_*} as the canonical vendor-neutral spelling for the
  env placeholders (SESSION_ID, EFFORT, SKILL_DIR); keep ${CLAUDE_*} as
  a permanent back-compat alias so imported skills keep resolving.
- Bash env: export TURNSTONE_SKILL_DIR and SKILL_RESOURCES_DIR
  unconditionally, but add CLAUDE_SKILL_DIR only when the host has not
  set it, so turnstone does not shadow a real value when it runs as a
  node inside Claude Code.
- Materialize skill resources before substituting the body, so
  ${TURNSTONE_SKILL_DIR} resolves to the concrete bundle path on the
  interactive path.

Sub-agent resource materialization and moving identity to a first-class
persona are left to follow-ups; ${TURNSTONE_SKILL_DIR} stays literal on
the task_agent path for now (unchanged from prior behavior).
2026-07-03 19:16:36 -07:00
Patrick Buckley 9bf8d5699b fix(test): harden resolve_when_pending — cancellable worker (review)
Address Copilot review: cancel() now signals the worker to stop (a
cancellation Event) and joins only when started, so a test that errors
before the approval registers can't leak the worker or resolve late into a
finished test. The worker reads _pending_approval via getattr so a UI
without it can't crash the thread into a silent death (leaving approve_tools
blocked the full timeout), and only resolves when it actually observed the
registration.
2026-07-03 19:16:16 -07:00
Patrick Buckley c0ff00a1ff fix(test): eliminate lost-wakeup race in approval-prompt tests
The UI-approval tests drive a blocking approve_tools() by firing
resolve_approval() from a fixed 0.05s threading.Timer. approve_tools does
_approval_event.clear() -> register _pending_approval -> wait(3600s); on a
slow/loaded runner the timer can fire the event's .set() BEFORE that .clear(),
so the wakeup is wiped and approve_tools blocks the full _APPROVAL_WAIT_TIMEOUT
(one hour) -- surfacing as an intermittent CI hang (observed on the 3.12 runner
~15% into the suite; fast runners win the race, so 3.11/3.13 pass the same
commit).

Replace the fixed-delay timer with resolve_when_pending() (tests/conftest.py):
it waits until the approval is actually registered -- which happens AFTER the
clear -- before resolving, so the set can never be lost. The helper mirrors
threading.Timer's start()/cancel() so the surrounding scaffolding is unchanged.
10 sites across 3 files; the verdict-delivery timer (bounded to its own 5s
budget, not a hang) is left as-is.

Validated: the 3 files pass 20/20 under single-CPU stress (taskset -c 0) with
no hang or thread leak.
2026-07-03 19:16:16 -07:00
Patrick Buckley 45010f5890 fix(eval): address Copilot review — checkout-agnostic docs + skill validation
- Docstrings/help said the treatment skill 'composes into the system
  message'. This harness runs on both checkouts (system on main, a context
  turn on the placement-refactor branch), so the wording now describes the
  natural set_skill composition path without asserting a placement.
- Validate each skill-bearing case's 'skill' shape up front (driver +
  CLI) so a malformed dataset fails with a clear error, not a mid-run
  KeyError. Pinned by test_rejects_malformed_skill.
2026-07-03 17:30:33 -07:00
Patrick Buckley 845df69031 feat(eval): skill-adherence measurement mode
Add a two-arm skill-adherence mode to the eval measurement substrate that
measures whether a NAMED skill changes tool-use behaviour, so skill-in-system
(main) can be compared against skill-in-context.

- _run_single_test gains skill/skill_mode: skill_mode builds HeadlessSession
  under natural composition (no system_prompt_override) and, for the treatment
  arm, seeds the skill into the temp DB and activates it via the real
  set_skill path so the skill body folds into the system message under test.
  skill_mode defaults False, so the optimizer/measure paths are unchanged.
- Thread skill/skill_mode through _run_and_score_subprocess, _run_iteration
  and _run_iteration_parallel (serial + parallel).
- run_skill_adherence: per case, run treatment (skill) vs control (no skill)
  n_runs each, score against expected_actions, report per-case lift =
  pass_rate(treatment) - pass_rate(control) and the mean lift. The control
  isolates the skill's causal effect.
- turnstone-eval --skill-adherence <dataset>: loads a skill-scenario dataset
  and prints a treatment/control/lift table.
- eval_skill_adherence.json: authored search-first / test-after-edit /
  changelog-update scenarios, chosen so the base model does not do the action
  by default.
- tests: plumbing proof (skill folds into system_messages for treatment,
  absent for control) + lift-math aggregation.
2026-07-03 17:30:33 -07:00
renovate[bot] d47d528d9a chore(deps): update github actions 2026-07-03 17:20:15 -07:00
Patrick Buckley 50d0e6343f fix(eval): drop dead session rebind flagged in review
The success path already extracts message_count/total_usage before the
break, so the session = None rebind was unused dead code (code-quality
review). Remove it; exception/timeout cleanup paths are unchanged.
2026-07-03 16:39:31 -07:00
Patrick Buckley 7053439e84 refactor(eval): split measurement core from prompt optimizer
turnstone-eval was misnamed: it was a prompt optimizer, not a measurement
harness. Split the 3252-line turnstone/eval.py into a strictly one-way
dependency (optimizer -> eval-core; core never imports the optimizer):

- turnstone/eval/core.py  measurement substrate — everything up to and
  including _run_iteration: provider detection, NullUI, HeadlessSession,
  the test runner, score_run, aggregation, and neutral reporting.
- turnstone/eval/cli.py   new measure-only `turnstone-eval` — the old
  --no-optimize path promoted to the whole job (one _run_iteration call,
  then print the summary table).
- turnstone/optimizer.py  the UCB self-modify loop and its multi-agent
  pipeline (analyst/optimizer/observer/diversifier/tool optimizer), now
  `turnstone-optimizer`; imports from eval.core only.
- turnstone/eval/__init__.py re-exports the core public API for
  back-compat (score_run, _match_action, _run_iteration, HeadlessSession).

_apply_tool_overrides lives in core (HeadlessSession needs it) rather than
alongside the other tree helpers, so the dependency stays one-way.

Breaking change: `turnstone-eval` now measures; use `turnstone-optimizer`
to optimize. Both code paths are behaviour-preserving — the moved function
bodies are byte-identical.
2026-07-03 16:39:31 -07:00
Patrick Buckley cf05ffee7d fix(console): persona admin UX - grid columns, base-prompt copy, default row action
- Add the missing #admin-personas grid-template so the table lays out as
  columns; it was the only admin table without its own template, so every
  cell collapsed into one implicit stacked column.
- Base prompt: accurate per-mode placeholders (required on create; blank
  keeps a built-in's shipped prompt on edit) plus a client-side required
  check on create. The old "empty = the kind's stock base prompt" copy was
  wrong now that create rejects a missing base_prompt.
- Set the default persona from the row ("set default", with a scope-default
  badge) to match the models table; drop the shelf checkbox and its
  create/edit/submit wiring.
2026-07-03 00:29:26 -07:00
Patrick Buckley bed776a308 style: ruff format server_schemas and server
ruff format --check flagged two lines: a long persona-picker Field
description in server_schemas.py and a watch-restore create() call in
server.py that fits on one line after the persona-kwargs merge.
Formatting only, no behavior change.
2026-07-03 00:29:26 -07:00
Patrick Buckley dbf389783e refactor(personas): file-backed built-in prompts, explicit source column
Built-in persona base prompts move from inline DB text / base.md into
prompts/personas/<slug>.md — code-owned, PR-reviewable, drift-proof.
base.md / base_coordinator.md become personas/engineer.md / orchestrator.md.

Prompt source is now explicit in storage instead of inferred in app logic:
a new base_prompt_file column plus CHECK (base_prompt IS NOT NULL OR
base_prompt_file IS NOT NULL) — two nullable columns, never both empty.
Resolution is a coalesce (base_prompt else load(base_prompt_file)), frozen
into the workstream stamp at creation. base_prompt_file marks a persona as
built-in (code-only, un-archivable); an operator override on a built-in is
allowed and wins over the file. "Inherit the kind default" is a
workstream-creation act (is_default), not a persona-row state.

Migration 063:
- seeds reference their file (base_prompt NULL); no runtime file reads —
  the backfill's frozen prompt text is inlined as a point-in-time snapshot
  so migration history stays self-contained and reproducible.
- every existing workstream is stamped by kind (creative -> writer, else
  the kind default), set-based (INSERT..SELECT via temp tables) with the
  persona column added after the bulk writes to shorten its lock window.

Storage guards (both backends): operators must supply base_prompt;
built-ins can't be archived or have base_prompt_file set via the API;
clearing an operator persona's only source is rejected.

Follow-ups reviewed alongside (#756): soft-set visibility docstring scoped
to per-process; _apply_persona_snapshot / _current_persona_snapshot own the
stamp round-trip; spawn approval-header args (skill/name/target_node)
flattened+capped like persona; server-side tool injection generalized to
replace-only (client-def gated, incl. the xAI include forwarding). Seed
copy revised (researcher soft; de-costumed prose; engineer de-biased).
New test_schema_parity asserts create_all matches the alembic head.

Closes #683 groundwork; ruff + strict mypy clean, full suite green.
2026-07-03 00:29:26 -07:00
Patrick Buckley 75c2e6c364 fix(personas): apply PR review feedback
The roster persona merge distinguishes key-absence (pre-persona node in a
rolling upgrade — preserve) from present-but-empty (authoritative
unstamped — accept), so a stale in-memory value can never mask the
snapshot on an immutable field. The node create route caps the persona
slug at 64 like the console proxy, keeping oversized values out of the
storage lookup and the reflected 400 text. The four persona admin
handlers drop their redundant function-local asyncio imports, and the
DELETE-route test moves its request out of the assert statement.
2026-07-03 00:29:26 -07:00
Patrick Buckley d152c504e1 feat(personas): revise seed prompt copy
The scribe, researcher, and executive prompts drop the infrastructure-team
costume and the demo-theater close — those framings suit the stock BASE
modules (whose job is today's default engineer/orchestrator behavior) but
narrowed personas meant for general use: a scribe summarizing meeting
notes is not a teammate, and the executive's verdict language works
without corporate staging. The behavioral substance is unchanged —
fidelity discipline for scribe, evidence discipline for researcher,
interrogate/delegate/verdict for executive, with the approvals boundary
still stated plainly.

The writer prompt loses 'use the analysis channel', a harmony-format
holdover from the CLI's single-provider days — the reasoning cue is now
format-neutral. Migration docstrings ride along: the workstreams.persona
column is documented as a slug carrier, and downgrade() now states the
capability-widening consequence of stripping stamps.
2026-07-03 00:29:26 -07:00
Patrick Buckley b65e5cae0e docs(personas): accuracy sweep — spec models, protocol contracts, page corrections
Spec models now describe what the endpoints do: ListPersonasResponse
declares the tool_inventory the shelf depends on, both console create
models declare persona, CreatePersonaRequest declares org_id, and
UpdatePersonaRequest documents the null-vs-absent split (null clears
base_prompt/tool_allowlist, null on flags/kinds is ignored). Console
OpenAPI regenerated.

Protocol contracts match the implementations: update_persona's return
covers the no-op case, create_persona's raises-list is complete, and
both extended row-shape docstrings gain their tail columns plus the
append-only rule. The workstreams.persona comments say slug, not
display name.

Page corrections from the docs review: personas.md documents the
creative_mode-to-writer migration conversion, the mid-session /resume
MCP-lever behavior, visibility-based nudge gating, the soft-set
prompt-cache cost, and the executive tool list — and drops internal
jargon. The changelog entry moves under [Unreleased] with the house
breaking-marker style and the auto-conversion note. coordinator-skills
and the API tour stop using persona to mean framing; governance,
api-reference, sdk, console, tools, and memory pick up the new
permission family, endpoints, kwargs, picker, and lever caveats.
2026-07-03 00:29:26 -07:00
Patrick Buckley 09c05733c6 test(personas): harden the guard suite — real paths over scripted events
The rank guard now derives needs_approval through the real _prepare_tool
on a bash call under an allowlisting persona instead of scripting the
flag, and asserts the approval gate actually fires. The row-shape guard's
source-grep is replaced with behavioral collector tests driving both
ws_created lanes (poll-diff and SSE relay), plus a proxy-forward twin and
a saved-list value assertion that would catch positional column mix-ups.

Receiving-side stamping gets its first HTTP coverage: create with an
explicit persona under workstreams.create only (selection needs no
persona perm), kind-mismatch and unknown-name 400s, omitted-persona
default stamping, the 503 on a failed default lookup, and the clean-None
legacy lane. Resume adoption is pinned end to end: a corrupt target stamp
leaves the session fully intact, an MCP-on stamp is refused when the
client was persona-gated at construction, and an MCP-off stamp drops the
live surface (listeners deregistered, toolsets reset). Soft-set
tool_search expansion recomposes the prompt exactly once; legacy sessions
never recompose.

Compaction legs run real flows now: spill plus the recall-pointer variant
under memory-off with recall visible vs hidden, and a full stamp
surviving compaction-then-resume. Migration 063 gains the downgrade
config-cleanup case (stamps removed, creative_mode preserved) and the
conversion idempotency case (already-stamped creative rows don't crash
the upgrade).

RBAC coverage goes cross-perm: read-only and write-only principals hit
every verb (a wrong-perm-name regression in any handler is now visible),
archive and default-flip succeed through PATCH, persona.* strings
round-trip the role editors and the overrides overlay, and the production
route table is asserted directly (no DELETE registered). Endpoint/storage
fixtures move off migration-seed names; storage hardening tests cover the
size caps, corrupt-row reads, the TypeError-to-ValueError ordering, the
duplicate-name race mapping, and the single-default backstop. Shell
asserts pin the new picker surfaces and drop the last persona-as-kind
wording.
2026-07-03 00:29:26 -07:00
Patrick Buckley 5d1d34cd82 fix(personas): close review findings across the envelope, resume, and RBAC lanes
Provider search gating (replace-only): native web search now stands in for
a client web_search def that survived the persona visibility filter — on
both OpenAI surfaces and both injection lanes (web_search_options, the
server_side_tools loop, and _convert_tools' capability lane). A scribe or
any envelope hiding web_search stays search-free on search-capable models;
coordinators and tool-less utility calls stop receiving search too.

Resume stamp discipline: resume() loads config and parses the target's
stamp BEFORE touching session identity/history, so a corrupt stamp raises
with the session intact instead of half-adopting and then 'repairing' the
target's stamp on the next config save. The MCP lever now follows the
stamp on mid-session adoption: an MCP-off stamp drops the live surface in
place (listeners deregistered, toolsets reset); adopting an MCP-on stamp
into a session whose persona gated the client off is refused loudly (the
surface cannot be rebuilt post-construction). The REPL /resume handler
reports these errors instead of crashing the CLI.

Fail-closed default lane: a FAILED default-persona lookup at create is a
503 (routes) / clear exit (CLI) instead of silently degrading to the
unstamped stock envelope; a clean 'no default configured' still creates
legacy. resolve_persona_for_kind reports storage-unavailable distinctly
from unknown-persona.

Soft-set governance: tool_search expansion under a persona visibility set
recomposes the system prompt so tool-gated policy segments land with the
tool they gate. MCP resource/prompt catalogs gate on read_resource /
use_prompt visibility. Spawn judge/audit projections carry persona (the
human approval header already did). Active-list rows carry persona like
their project_id twin.

RBAC catalogs: persona.{create,read,write} join _VALID_PERMISSIONS and
the roles-editor sections, making the documented grant-outward path real.

Storage hardening: default-persona invariants move to a shared _utils
helper (validate + demote) with a pg advisory xact lock serializing
promotions and a post-promote single-default assertion; create maps the
unique-name race to the same ValueError as the pre-check; reads validate
JSON shape loudly (naming the persona); serialize enforces size caps;
field validation runs before invariant checks so malformed input is a 400,
never a TypeError-500. org_id guards explicit null and caps at 64.

Also: base_override='' means 'no override' at the compose boundary;
persona tag flattened/capped before the spawn approval header; /creative
redirect resolves the writer persona before advertising it; memory-nudge
gating unified through _nudges_enabled.

Provider/row-shape tests updated to the new contracts (the old ones
pinned the injection hole and the pre-persona row shape).
2026-07-03 00:29:26 -07:00
Patrick Buckley e2dcd2bd6b fix(personas): apply review findings — stamp adoption on fork/restore, PATCH semantics, gating
Review pass over the branch surfaced real defects, all fixed here with
regression guards:

- Fork-resume (resume_ws) adopts the SOURCE workstream's stamp,
  resolved pre-construction so all four levers (including the
  construction-time MCP gate) bind the fork; a corrupt source stamp is
  a loud 400, an unstamped legacy source forks unstamped — never the
  kind default. Watch-restore and CLI --resume thread the stamp the
  same way, closing an MCP leak where a restored MCP-off workstream
  re-merged the catalog.
- SessionManager.open parses the stamp inside the install guard so a
  corrupt stamp releases the reserved slot; a retry reproduces the
  loud error instead of 'already tracked'.
- Mid-session resume() adopting a stamp rebuilds the tool_search
  pathway to match (hard set drops it, soft set force-constructs it);
  soft persona sets survive the global tool-search setting being off.
- Memory nudges gate on actual memory-tool VISIBILITY, not just the
  memory lever, so an allowlist that hides the tool also silences the
  nudges that point at it; post-compaction resume gets a no-recall
  nudge variant when the pointer would dangle.
- Console PATCH: explicit null flags from UpdatePersonaRequest no
  longer archive the persona or flip levers on a rename; multi-kind
  personas survive a shelf edit; admin list ships the per-kind
  tool_inventory so the shelf checklist tracks the server inventory
  instead of a hardcoded JS list; admin CRUD moved off the event loop.
- Migration 063 converts legacy creative_mode workstreams to the full
  writer stamp (downgrade removes all persona keys).
- REPL: /new passes the persona; /workstreams unpacks the widened row.
- Shared resolve_persona_for_kind is the single eligibility rule for
  the HTTP handler, CLI, and spawn precheck; spawn_batch memoizes the
  persona lookup; ToolSearchManager.is_expanded gives the visibility
  tail an O(1) probe.
2026-07-03 00:29:26 -07:00
Patrick Buckley 0d6d7ebae1 docs(personas): concept doc, CHANGELOG 1.7 entry with /creative breaking note
docs/personas.md covers the four levers, the resolve-once/stamp-forever
snapshot semantics, the seed matrix, per-surface selection, authoring
rules, and RBAC; architecture.md's config-persistence paragraph swaps
the removed creative_mode for the persona stamp.
2026-07-03 00:29:26 -07:00
Patrick Buckley 9706fc5d9c test(personas): guard suite — rank guard, levers, spawn, RBAC, immutability
The 15 guards from the design brief: the approval path is untouched
under any persona (rank guard); empty-toolset personas compose no tools
block and put zero definitions on the wire; the tool_search escape hatch
is soft when included (discovered tools union with the allowlist) and
hard when omitted (pathway disabled, including native defer_loading);
memory-off suppresses recall injection, the memory tool, and
memory-directed nudges while behavioural nudges and task-agent tools
survive; MCP-off is session-wide and refresh-proof; spawn validates
persona at prep time and never inherits the parent's; task_agent's
schema stays persona-free; the stamp is immutable, survives
SessionManager.open threading, and corrupt stamps fail construction
loudly; mandatory prompt policies compose under every persona; CLI
resolution (extracted to resolve_cli_persona_kwargs for testability)
loads seeds, exits clearly on unknown names, and adopts the resume
target's stamp; persona edits/archives never touch stamped workstreams;
and the row-shape contract twins carry the persona field.

RBAC endpoint coverage: admin CRUD 403s without persona.* and succeeds
with it; the picker feed needs no persona permission and hides archived
personas; invariant violations surface as 400s; DELETE is 405.
2026-07-03 00:29:26 -07:00
Patrick Buckley 2329cb8ad5 feat(webui): persona picker, Service Hatch shelf, labels, row-emitter sweep, kind-id reclamation
Creation surfaces: the console launcher, the server webui new-workstream
dialog, and the dashboard composer all gain a Persona select fed by the
shared personas.js data layer (module cache + fingerprint + never-reject
fetch + window.TurnstonePersonas bridge, cloned from projects.js), kind-
filtered with the kind default preselected so a zero-touch launch is
byte-identical to today.

Authoring: a Personas tab in the console Manage surface (Governance
group, persona.read-gated) with a Service Hatch shelf exposing exactly
the four levers — base prompt, tool-visibility checklist (kind inventory
+ free-text row for MCP/dynamic names; tool_search membership decides
soft vs hard), MCP and memory toggles — plus kinds, default flip, and
archive.  No delete action anywhere (archive-only lifecycle).

The workstream wears it: SavedColumns.persona() on both saved tables,
hover/aria labels on the rail rows (raw slug fallback keeps archived
personas labelling their workstreams), and the full row-emitter sweep —
storage projections (list_workstreams tail, get_workstreams_batch,
list_workstreams_with_history) on both backends, the server dict
builders and ws_created events, both _coordinator_rows lanes, the
collector delta + pseudo-node paths, and the console cluster-create
proxy that rebuilds its body.

Naming reclamation: the launcher kind-toggle ids/classes that squatted
on 'persona' (launcher-personas, persona-coordinator/-interactive,
.persona-btn/.persona-led/.persona-tag) are renamed to kind-* before
'persona' becomes user-facing vocabulary, along with the prompts-module
and test wording that used persona to mean kind.
2026-07-03 00:29:26 -07:00
Patrick Buckley 54ebb24374 feat(personas): core stamping, four-lever application, create/spawn/SDK threading; remove /creative; add --persona
The persona resolved at creation is snapshotted into workstream_config
(five keys, all-or-none) and applied ONLY from the stamp — the personas
table is never read post-create, so edits/archives never touch existing
workstreams, and a corrupt stamp fails construction loudly instead of
silently reverting to a default envelope.  Legacy pre-063 workstreams
carry no keys and keep today's behavior byte-for-byte.

The four levers (turnstone/core/personas.py holds the codec):

1. Base override — compose_system_message(base_override=...) replaces
   exactly the BASE module; ENV/CONTEXT/TOOLS/POLICIES keep composing so
   mandatory prompt policies ride on top of every persona.  This also
   closes the old /creative hole where the fork bypassed composition
   (no CONTEXT, no DB policies).
2. Tool visibility — the allowlist intersects both the composition name
   set (TOOLS block self-suppresses, tool-gated policies drop, the
   memory advisory drops) and the END of _get_active_tools so the wire
   never advertises hidden tools.  tool_search in the set = soft
   (discovered tools union with the allowlist via the session's
   expanded-names set); absent = hard (the whole pathway is disabled,
   covering provider-native defer_loading, which has no synthetic name
   to filter).  Persona sets force client-side tool search.
3. MCP gate (session-wide) — an MCP-off persona drops the client
   reference at construction: no merge into _tools OR _task_tools, no
   listeners, refresh callbacks inert, resource/prompt catalogs gone.
4. Memory (own hands only) — no recall injection, memory-directed
   nudges suppressed (MEMORY_NUDGE_TYPES; behavioural nudges keep
   firing), memory tool hidden.  _task_tools is NOT filtered; compaction
   spill/markers are never persona-gated, and the post-compaction recall
   pointer is emitted only when the recall tool is actually visible.

Threading: the create handler resolves once (explicit name -> 400 on
unknown/disabled/kind-mismatch; empty -> the kind's default; pre-seed DB
-> unstamped legacy) and stamps via constructor kwargs + config keys +
the workstreams.persona column; SessionManager.open threads the stamp
pre-construction exactly like the saved model alias.  Non-fork resume
adopts the target's stamp so _save_config can't clobber it.  spawn /
spawn_batch gain a persona arg with prep-time validation (children are
interactive-kind; omitted = kind default, never the parent's).  Python +
TS SDKs, OpenAPI specs, the picker feed GET /v1/api/personas (authed, no
perm), and console admin CRUD /api/admin/personas (persona.* perms,
archive-only — no DELETE) round out the surface.

BREAKING: /creative is removed (the REPL command now points at the
writer persona); turnstone --persona <name> is the replacement.  Also
fixes the CLI session factory, which TypeErrored on the project_id
kwarg the shared InteractiveAdapter passes unconditionally.
2026-07-03 00:29:26 -07:00
Patrick Buckley cc48144a35 feat(storage): personas table, CRUD, seeds, perms (migration 063)
Adds the personas template shelf (#683): migration 063 creates the
personas table (tri-state tool_allowlist, per-kind is_default, archive
via enabled=0 — no hard delete) plus the workstreams.persona display
column, seeds the six launch personas (engineer/orchestrator as
zero-touch per-kind defaults; scribe/researcher/writer/executive as
curated envelopes), and grants persona.{create,read,write} to
builtin-admin following the 062 pattern.

Storage: list/get/get_by_name/get_default/create/update on both
backends, with the default-persona invariants (exactly one per kind,
single-kind, enabled, not archivable, demote-on-flip) enforced in the
storage layer and the JSON serialization shared via _utils so the
backends cannot drift.
2026-07-03 00:29:26 -07:00
Patrick Buckley 8f347da653 fix(registry): normalize config.toml context_window=0 to auto-detect
context_window=0 is the documented auto-detect sentinel -- "inherit the
CLI-detected window." The DB loader applies it (row.get(k, 0) or
context_window); the config.toml loader did not (entry.get(k, default)
only substitutes a MISSING key), so an explicit context_window = 0
leaked a literal 0 downstream, zeroing every budget that reads
ModelConfig.context_window -- judge lowering, session compaction. The DB
loader's comment even claimed config.toml shared "the same fallback
chain," which was false.

Match the DB loader at the source. The judge-side _positive_window
coercion stays as defense-in-depth, but its comments no longer misframe
0 as garbage -- it's a valid sentinel, now normalized at load.
2026-07-02 22:00:19 -07:00
Patrick Buckley 77de11a97d fix(judge): coerce non-positive judge windows + real output-guard fallback
Two window-sourcing edge cases the first pass missed:

- config.toml models can carry context_window=0 (that load path lacks
  the DB loader's 0-inherit normalization). The getattr guard caught a
  missing attribute but not a present 0, which would zero every budget
  and make honest_truncate drop everything. A shared _positive_window
  helper now coerces any non-positive / non-int window to the next sane
  candidate (the session window) then a floor, on every resolution path
  in both judges.

- The output-guard judge's session-model fallback keyed off
  provider.get_capabilities(), which reports 200k for local models --
  the fictitious-window bug the alias path already fixes. It now takes
  the session's real (config/registry-aware) window, like IntentJudge.

output_guard_judge shares _CHARS_PER_TOKEN from judge rather than
duplicating it, now that it imports the coercion helper anyway.
2026-07-02 22:00:19 -07:00
Patrick Buckley 587828c57e fix(judge): source the output-guard judge's real window + oversize backstop
The output-guard LLM judge fed the model the full tool output with no
window awareness, so on a small-window local judge a large output
overflowed into an opaque provider error and fell silently to
heuristic-only — the opted-in LLM tier vanished without a trace.

Resolve the judge's real context window from the registry's per-model
config (the static capability table reports 200k for every local
model), and add an up-front oversize guard: when the assembled prompt
would exceed the window, skip the doomed call and record a labelled
llm_error the operator can see instead of a silent no-op. The
heuristic tier runs first, so its verdict still stands.

Also drop the fixed 500-char cap on the tool_args framing field: like
the output under review it now lowers whole, bounded only by the same
window backstop, never by a default clip of a normal argument.
2026-07-02 22:00:19 -07:00
Patrick Buckley b0a5fa6856 fix(judge): give the intent judge the full tool arguments
The func_args projection in _evaluate_intent is the intent judge's
entire view of a pending call's arguments, yet it lowered only a
narrow field per tool: edit_file reached the judge as {path} with the
edits stripped, and skills mutations built their projection and never
assigned it, so the judge ruled on {}. A small local judge denied a
legitimate multi-edit edit_file at 95% confidence as "malformed,
missing old_string/new_string" on exactly this gap.

Project the full risk-relevant surface per tool — edits, file content,
timeouts, model overrides, skill risk fields, task status and
ordering, MCP resource URIs and prompt arguments — and add the
read_resource / use_prompt branches that previously fell through to an
empty {}.

Truncation is now a backstop, not a default. Arguments lower whole up
to the judge model's real context window, sourced from the registry's
per-model config rather than the static capability table (which
reports 200k for every local model and would over-budget a small local
judge into overflow). Only a genuine overflow truncates, with an
explicit dropped-character marker; the untruncated arguments always
remain in the trajectory. The verdict's persisted and streamed copy
carries a separate 16 KB backstop against pathological payloads.

A parametrized guard test asserts every gated tool projects a
non-empty argument view, so the silent-starvation failure mode fails
CI instead of shipping.
2026-07-02 22:00:19 -07:00
Patrick Buckley 73e7972fb8 fix(session): PR feedback + CI typecheck/test failures
- typecheck: dropping _acting_user_id from the SessionUI protocol (it made
  the attribute required, breaking NullUI's structural match and making
  TerminalUI abstract). _emit_state now narrows to SessionUIBase before
  assigning — the field belongs to the web-fanout UIs, not the protocol
  contract that CLI/eval UIs also satisfy.
- test: two mock queue_message stubs (attachments-endpoint fake,
  coordinator adapter double already fixed) needed the new
  interjector_user_id kwarg; the endpoint fake was raising TypeError ->
  queue_full. Added it and a negative test that a non-SessionUIBase UI is
  skipped by _emit_state.
- review (Copilot): _load_persisted_senders now latches _db_senders_loaded
  only if self._ws_id still matches the workstream it queried, so a
  concurrent resume() can't mark the read done for a workstream whose
  senders were never loaded.
- review (Copilot): corrected the acting-user-id comments in three places
  — it carries the owner id even single-user (the gate no-ops because it
  equals the viewer); it is empty only on unauthenticated lanes / before
  first state emit.

Note: the storage-protocol '...' stub flagged by the code-quality bot is
the file's universal convention (262 stubs, zero NotImplementedError);
left as-is for consistency.
2026-07-02 19:23:04 -07:00
Patrick Buckley 6424f73da4 feat(console): extend cross-user send gate to the coordinator surface
Coordinator workstreams will hit the same cross-user issue once MCP is
enabled there, so wire the same protection now.

- CoordinatorAdapter.send takes acting_user_id: binds it on a fresh turn
  (so MCP creds + the acting-user signal are correct) and passes it to
  queue_message on the interject path (CrossUserInterjectionError block).
  The create-time initial dispatch passes the creator's id.
- ConsoleCoordinatorUI.on_state_change includes acting_user_id (mirrors
  WebUI); the mid-turn-connect replay already carries it via the shared
  make_events_handler. The coordinator's user-facing /send route already
  reuses make_send_handler, so it inherited the interjector guard + 409.
- coordinator.js gains the same gate as the interactive pane: tracks the
  acting user from state_change, blocks send when busy AND acting !=
  viewer, and handles the 409 cleanly.

Drive-by hygiene (requested): the _verdictSig join used a raw U+001F
byte embedded in the source; replaced with String.fromCharCode(0x1f) —
identical runtime, ASCII-clean source (no invisible control char in the
file).
2026-07-02 19:23:04 -07:00
Patrick Buckley 9c1b76b632 feat(webui): disable send for non-acting participants while busy
The UX complement to the server-side cross-user interjection block: on a
shared workstream, while another participant's turn is in flight, this
viewer's send button is disabled so they don't click into a 409 (and
can't drive tools under the initiator's credentials).

Backend signal (the linchpin — the acting user was tracked but never
surfaced to clients):
- ChatSession._emit_state pushes the acting user (turn initiator, owner
  fallback) onto its UI (_acting_user_id on SessionUIBase).
- server.WebUI.on_state_change includes acting_user_id in the broadcast
  state_change event; the mid-turn-connect replay (session_routes) adds
  it too, so a client joining mid-turn learns who holds it. Id only (a
  uuid the client compares) — no name, no storage lookup on the hot path.

Frontend:
- auth.js retains the opaque user_id from /whoami (ts.user_id) — kept
  separate from the display username, used only for id comparison.
- composer.js gains an independent hard-block axis (setSendBlocked /
  _reconcileDisabled) so send can be disabled even in queueWhileBusy mode.
- interactive.js tracks the acting user from state_change, blocks send
  when busy AND acting_user_id !== the viewer's own id, and handles the
  409 as a clean message (reactive fallback for the click-beats-event
  race) instead of a generic connection error.

Degrades gracefully: single-user workstreams (acting user == viewer) and
older backends (no acting_user_id) never engage the gate.
2026-07-02 19:23:04 -07:00
Patrick Buckley 2ba54266c6 feat(session): block cross-user mid-turn interjections
A mid-turn interjection folds into the current turn under the
initiator's identity — bind_acting_user deliberately does not rebind
mid-turn — so on a shared workstream a second participant's queued text
would run any tools it triggers under the initiator's MCP (oauth_user)
credentials (confused deputy) and be stamped with the initiator's sender
label (misattribution). Rather than fold it in, reject: queue_message
now takes the authenticated interjector_user_id and raises
CrossUserInterjectionError when it differs from the current acting user.
The send route surfaces it as 409 cross_user_interjection. Only an
authenticated non-acting participant is blocked — self-interjection,
single-user workstreams, and unauthenticated internal lanes (empty id,
e.g. the coordinator adapter) are unaffected.
2026-07-02 19:23:04 -07:00
Patrick Buckley b9f95c357c fix(session): address ultrareview findings on shared-workstream branch
Cloud multi-agent review of the three follow-up commits surfaced 15
verified defects; this addresses them.

Security / correctness:
- output_guard was blind to the new sender-label trust marker: add
  fence.SENDER_LABEL_TAG to the forgery/leak detector and thread a
  second trusted nonce (trusted_sender_label_nonce) through
  evaluate_output/_check_marker_forgery so a forged or leaked
  sender-label block in tool output is flagged like an operator marker.
- Attachment-derived text (PDF extraction, audio transcript, perception
  output) bypassed sender-label neutralization because it materializes
  after _inject_sender_labels runs; neutralize it at each fallback site.
- _recompute_shared_state now runs on every compose (moved out of the
  non-creative branch) so a creative-mode resume can't leave shared
  state stale.
- /new and rewind/retry now reset shared state (were leaking the prior
  conversation's participant set / keeping a workstream latched 'shared'
  after its only second-participant evidence was deleted).
- Non-fork resume and /new remint both trust nonces; carrying a nonce
  across a workstream switch would let a token leaked in one forge a
  marker in another.
- _senders_dirty is only cleared once the persisted-sender read has
  actually landed, so a transient storage error retries within the turn.
- ws_id snapshot guard in _recompute_shared_state discards a result if
  resume() swapped workstreams mid-scan (MCP-callback race).
- recall/history search is scoped to the acting sender's visibility;
  the shared-workstream declaration now names that exception so the
  model doesn't read a filtered 'no results' as 'no record exists'.

Cleanup:
- fork skips the redundant persisted-sender read (its rows were just
  bulk-written); _maybe_note_new_participant goes through the single
  recompute entrypoint; senders_from_user_meta reuses _source_meta_from_json.
- fence.wrap docstring names the sender-label caller as a third
  untrusted-host boundary.

Tests: end-to-end compaction-narrowed resume recovery, hostile
display-name fence break-out, sender-label output-guard leak/forgery,
and the two-nonce independence.
2026-07-02 19:23:04 -07:00
Patrick Buckley c8f0c0cf90 chore(session): house-style polish on shared-workstream feature
- q-2: document the deliberate username-first display-name precedence in
  _resolve_display_name (diverges from auth.py's display_name-first
  because sender labels must match the owner-banner identity kind).
- q-3: drop change-lineage comments referencing the separate acting-user
  credential fix (tombstone noise once merged).
- q-4: tighten the plain-text attachment assertion from a tolerant
  subset check to exact shape + _sender value now that the stamp is
  deterministic.
- q-5: drop the contributor-local bare 'etc/' from .gitignore.
2026-07-02 19:23:04 -07:00
Patrick Buckley 21efeece32 fix(session): fence sender labels; move shared-ws behavior to a declaration
sec-1: the [message from <sender>] label was plain text, so a participant
could type a look-alike in their own message and impersonate another
sender to the model. Labels are now wrapped in a nonce-delimited
[start sender-label_<nonce>] ... [end sender-label_<nonce>] fence (new
fence.SENDER_LABEL_TAG, distinct value from the operator nonce) whose
token lives in the cached system prefix; participant content is
neutralized so typed look-alikes are defanged. A new
build_shared_workstream_declaration pins the token as the sole authentic
label.

sec-2 + q-1: the CONTEXT banner no longer embeds behavioral prose. It
carries a terse owner line + shared flag; the attribution rules, the
authenticity declaration, and the (now narrowed) tool-credential claim
move into the shared-workstream declaration. The credential claim is
corrected: per-participant credentials apply to MCP (OAuth) tools only;
built-in tools and skills run under the server/owner identity.

perf-3: _inject_sender_labels resolves each distinct sender's display
name once per call instead of once per turn, capping blocking storage
lookups at one per sender on the uncached error path.
2026-07-02 19:23:04 -07:00
Patrick Buckley 7f20b1bc84 fix(session): durable shared-workstream state + fork sender persistence
- _known_senders/_shared_workstream are now monotonic: union-only growth,
  latched shared flag, seeded once per workstream from a full-history
  distinct-sender read (new StorageBackend.list_message_senders) so
  compaction narrowing the resumable slice can no longer forget
  participants (duplicate join notes) or flip the banner back to
  single-user framing (prompt-prefix cache churn).
- Recompute is memoized per turn (invalidated on stamped user-turn
  append); system-prompt composition no longer pays an O(n) trajectory
  scan on every recompose.
- resume() resets the state: the monotonic guarantees are per
  workstream, not per session object.
- resume(fork=True) bulk-persist now carries the user-turn sender stamp
  into the fork's meta column (was: _source_meta only, which dropped
  attribution for every forked user turn on reopen).
2026-07-02 19:23:04 -07:00
metaclassing 212d1922e5 Multi-user chat context clarification and tool improvements (#750)
* multiuser chat fixes for identity clarity and obo oauth token selection during tool calls

* added some missing context to the session so that the llm would know what session/project to reference in tool calls

* updated to address copilots issues and excluded a local config folder

* I think this resolves the cicd failures

---------

Co-authored-by: pow3rtool <root@pow3rtools>
2026-07-02 16:12:47 -07:00
553 changed files with 131865 additions and 15138 deletions
+5
View File
@@ -0,0 +1,5 @@
# Funding platforms for the GitHub "Sponsor" button.
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: [eous]
custom: ["https://paypal.me/eousphoros"]
+27 -25
View File
@@ -14,8 +14,8 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
- run: pip install pre-commit
@@ -25,8 +25,8 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
- run: pip install mypy
@@ -35,29 +35,31 @@ jobs:
test:
runs-on: ubuntu-latest
# Cap a hung run at 20 min instead of riding GitHub's 6-hour default
# (a flaky-hang run otherwise streams -v output for hours).
timeout-minutes: 20
# Cap a hung run at 30 min instead of riding GitHub's 6-hour default
# (a flaky-hang run otherwise streams -v output for hours). Was 20;
# the suite's growth (~9.7k tests, coverage-instrumented, 3-version
# matrix) started brushing the old cap on healthy runs.
timeout-minutes: 30
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: ${{ matrix.python-version }}
# Node is required by tests/test_renderer_js.py — without
# explicit setup, that suite silently skips if the runner
# image happens not to ship Node, masking regressions in
# the browser-side renderer.
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
- run: pip install -e ".[test]"
# -v lists each test id as it starts (pytest prints the nodeid at
# logstart), so a hang names the culprit on the last line instead of
# riding the job timeout with only a trail of "..." dots.
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -v
- run: pytest tests/ -m "not live and not e2e_recovery" --cov=turnstone --cov-report=term-missing --cov-report=xml -v
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
@@ -66,7 +68,7 @@ jobs:
test-postgres:
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
services:
postgres:
image: postgres:18
@@ -82,23 +84,23 @@ jobs:
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
- run: pip install -e ".[test]"
- run: pytest tests/ -m "not live" --storage-backend=postgresql -v
- run: pytest tests/ -m "not live and not e2e_recovery" --storage-backend=postgresql -v
env:
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
wheel-completeness:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
- run: pip install build
@@ -151,8 +153,8 @@ jobs:
lock-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
uv-version: "0.9.18"
- run: uv lock --check
@@ -160,11 +162,11 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
uv-version: "0.9.18"
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
- run: uv sync --frozen --all-extras
@@ -188,8 +190,8 @@ jobs:
run:
working-directory: sdk/typescript
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
- run: npm ci
-46
View File
@@ -1,46 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
if: github.event.pull_request.head.repo.full_name == github.repository
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # post the review + inline comments
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@6c0083bb7289c31716797a039b6367b3079cc46e # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: 'renovate[bot]' # let Renovate PRs get reviewed
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
-63
View File
@@ -1,63 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
) || (
github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
) || (
github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@claude') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)
) || (
github.event_name == 'issues' &&
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # post comments/reviews when @-mentioned on a PR
issues: write # post comments when @-mentioned on an issue
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@6c0083bb7289c31716797a039b6367b3079cc46e # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
+3 -3
View File
@@ -33,7 +33,7 @@ jobs:
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
@@ -54,7 +54,7 @@ jobs:
- name: Log in to GHCR
if: steps.tag.outputs.skip == 'false'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -78,7 +78,7 @@ jobs:
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
if: steps.tag.outputs.skip == 'false'
- name: Build and push
+4 -4
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
@@ -50,7 +50,7 @@ jobs:
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
if: steps.tag.outputs.skip == 'false'
with:
python-version: "3.14"
@@ -58,12 +58,12 @@ jobs:
if: steps.tag.outputs.skip == 'false'
- run: python -m build
if: steps.tag.outputs.skip == 'false'
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
if: steps.tag.outputs.skip == 'false'
- name: Create GitHub Release
if: steps.tag.outputs.skip == 'false'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
with:
tag_name: ${{ steps.tag.outputs.tag }}
generate_release_notes: true
+2 -2
View File
@@ -31,8 +31,8 @@ jobs:
# Floor and ceiling of the example's requires-python (>=3.11).
python-version: ["3.11", "3.13"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[test,dev]"
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
fi
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ steps.ref.outputs.head_ref }}
+2
View File
@@ -28,3 +28,5 @@ tools/skill_audit_analysis/data/
tools/skill_audit_analysis/output/
design_ideas/
.claude/
docs/design/
/.idea
+858 -4
View File
@@ -6,13 +6,867 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) for
version numbers (`X.Y.Z`, with `X.Y.ZaN` / `bN` / `rcN` for pre-releases).
Three release tracks are maintained — the current stable, one prior
stable, and the experimental line:
Two active release tracks are maintained — the current stable and the
experimental line:
- **`stable/1.5`** — patch-only (`v1.5.x`)
- **`stable/1.6`** — patch-only (`v1.6.x`)
- **`stable/1.7`** — patch-only (`v1.7.x`)
- **`main`** — experimental (next major)
Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
## [Unreleased]
### Added
- **`server_parses_reasoning` model capability.** Declare it on a model
definition whose backend segregates reasoning into its own channel (a
vLLM launched with a reasoning parser, a commercial provider): the
inline think-tag scan turns off on every lane — interactive and
drained alike — so content is trusted verbatim and prose that merely
quotes a tag can no longer be misrouted into the reasoning lane, and
the utility lanes stop suppressing reasoning they'd otherwise pin off.
Default off for local lanes, preserving the passthrough-server
behavior; the built-in capability tables declare it for every real
commercial endpoint (known models and table-miss defaults alike),
which also removes the quoted-tag false positive from those lanes.
- **Per-model Entra gateway authentication.** Model definitions can bind either
a caller-delegated OBO token (`entra_obo`) or a shared app-identity token
(`entra_app`) through the provider SDK credential surface. Mints reuse the
encrypted cluster token cache, refresh-rotation CAS, and advisory locking;
add a host-local memo, failure cooldown, long-lived mint HTTP client, audience
allow-list/permission boundary, identity-unlink purge, and optional
`model.auth_fail_closed` refusal policy. Delegated identity now propagates
through judge, output-guard, and principal-scoped perception lanes, and
unattended watch restoration reacquires the persisted workstream owner.
Ownerless OBO calls and dynamic aliases without a real static fallback always
fail closed; grant modes are never silently switched. Static authentication
remains the default.
- **Compaction is visible now: lifecycle events, a progress bar, and a
persistent transcript card.** Context compaction (manual `/compact` and
auto) emits a first-class `compaction` SSE event
(`start` / `progress` / `end` — see the API reference) instead of loose
info lines. The web UI renders an in-transcript card with a real progress
bar (determinate `part k of N` during chunked summarization, indeterminate
for single-call compactions) that settles into a result card — token delta
plus the summary behind a fold — in both the interactive pane and the
coordinator viewer. The result survives reloads: the persisted compaction
marker now projects through `/history` as a `role="system"`,
`source="compaction"` entry (resume/export/search unchanged), stamped with
the end event's id so repaint and SSE replay can't double-render. The
marker's `meta` additionally records `before_tokens` / `after_tokens` /
`trigger`. Python and TypeScript SDKs gain a typed `CompactionEvent`.
- **One provider transport: every model call now streams (#831).**
The per-adapter non-streaming entry (`create_completion`) is retired;
single-shot lanes — judges, titles, compaction, web-fetch extraction,
perception, eval, optimizer — sample through the same streaming entry
the chat loop uses and accumulate via one shared drain, so request
shaping can no longer drift between the two consumption styles. Two
operator-visible consequences: long single-shot generations (a thinking
model composing a title, a slow local judge) no longer sit in a single
blocking read that can hit client read-timeouts — the same reason the
Anthropic adapter already streamed internally — and judge timeouts now
*abort* the underlying HTTP read instead of abandoning a worker thread
on a dead call. Because every call now streams, an alias pointed at a
model or org that cannot stream (OpenAI's verified-org streaming
entitlement, a gateway api-version predating `stream_options` — e.g.
older Azure OpenAI deployments) fails at request time where 1.7's
non-streaming single-shot call succeeded; remediation is on the
serving side (verify the org, bump the api-version/gateway) — there is
deliberately no per-model non-streaming fallback left to configure. These lanes are also complete-or-error now: a stream
that ends without any finish signal is treated as a generation that
died mid-response and retried, instead of storing the partial text as
a clean result (previously a half-generated compaction summary could
silently replace real history). Caveats: these lanes now carry the
same `stream_options: {include_usage: true}` the chat loop always
sent — OpenAI-compatible servers old enough to *ignore* it stop
producing usage rows on these lanes, and servers strict enough to
*reject* unknown fields (pre-2024 llama.cpp/proxy builds) will 400 —
such a server already couldn't serve turnstone's chat loop, but a
judge/utility alias pointed at one worked on 1.7 and needs to move to
a current server. Transient mid-stream deaths (connection drop, proxy
hiccup) are re-issued in place up to twice with exponential backoff —
the retry the SDK's request loop used to provide these lanes
invisibly. Each lane accepts its own terminal marker (Anthropic
`message_stop`, Responses terminal events); a lax server/gateway that
never sends any terminal signal needs
`{"finish_reason_optional": true}` in the model definition's
capabilities JSON, which restores 1.7's tolerance (clean end-of-stream
after output = completion) for that model on every lane — without it
such streams fail as died-mid-generation, because SSE gives no way to
tell the two apart and the default favors catching truncation. The
unread `supports_streaming` capability flag (and its admin tile) is
gone; the o-series models it described are dropped from the capability
table entirely (see Removed).
- **One turn interface for every model call: `core/model_turn.py` (#827).**
Judges (intent + output guard), perception, title generation, compaction,
web-fetch extraction, the eval harness, the optimizer's meta lanes, and
task agents all advance a trajectory through the same plant-call
primitive the agent seam pioneered — Turn IR in, one shared lowering
(argument sanitize → minted-id restore → vLLM reasoning attach), one
shared re-ingest (blank-id repair → native-lane finalize). The judges'
hand-built OpenAI-dict path is gone, and with it the Gemini judge's
tool-blindness: evidence tools now work on Google models because the
native lane round-trips `thought_signature` (with pairwise repair for
blank-id compat responses). Provider adapters still take lowered wire
dicts — the transport collapse and main-loop migration are tracked as
#831 / #832.
- **task_agent keeps its model's reasoning across its own tool loop — on
every provider lane.** A task agent's replayed turns now carry the
provider-native reasoning lane the model produced — Anthropic thinking
blocks with their signatures (commercial or an anthropic-compatible
server), OpenAI Responses reasoning items, Gemini `thought_signature`
fidelity blocks, and the reasoning text a vLLM `--reasoning-parser` /
llama.cpp `reasoning_format` surfaces on the Chat Completions lane —
instead of each turn being rebuilt from text + tool calls with the
reasoning dropped. On a thinking model this restores reasoning continuity
across the agent's own multi-turn tool use. On the wire the agent's
session-minted sub-tool ids are mapped back to the provider's own ids
(`restore_provider_tool_ids`), so the native block — replayed verbatim,
its signature never touched — the `tool_calls` mirror, and each tool
result always agree; internally the minted ids still key the live card,
recall, and the cancel ledger unchanged. Replay honors the same per-model
`replay_reasoning_to_model` flag the main loop uses on every lane: the
vLLM Chat-Completions field replay keeps its server-type gate, and
llama.cpp stays capture-only, matching main-loop behavior. The native
lane is finalized by the same shared builder as the main loop's, so the
two harnesses cannot drift.
- **Background shells: `bash` gains `run_in_background`, plus `bash_output` /
`kill_shell`.** Setting `run_in_background=true` starts the command as a
detached shell and returns immediately with a `bash_N` handle — "start a dev
server, use it in a later call" is back as an explicit opt-in (the shape
follows the convention the major coding agents converged on). `bash_output`
returns only output produced since the previous read (optionally filtered by
a regex) plus status and exit code; `kill_shell` terminates the shell's
whole process group. Output is buffered per shell with a drop-oldest cap, so
a chatty server can't grow memory unbounded. When a background shell exits,
a system notice lands at the next seam (waking an idle workstream if
needed). Shells survive a generation cancel, die with the workstream, and
never outlive a task_agent that started them; anything a background shell
itself backgrounds is still reaped when that shell exits — the no-leak
guarantee below is unchanged.
### Changed
- **Log event rename: `drain_stream.post_finish_blip` is now
`stream.post_finish_blip`; its `usage_captured` field is retained.** The
single-shot drain normalizes mid-body transport deaths through the same
`transport_guarded` wrapper the interactive loop uses, so its
post-finish-blip tolerance logs under the wrapper's event name. Update
any external log filters pinned to the old name; the drained result's
possible `usage=None` on a post-finish blip is unchanged and documented
on `drain_stream`.
- **Breaking (1.8): compaction feedback moved from `info` events to the
typed `compaction` SSE event.** Pre-1.8 SSE/SDK clients that ignore
unknown event types no longer see compaction lines (they are
deliberately not dual-emitted — dual emission would double-render on
every current client). Consume the `compaction` lifecycle event (see
the API reference and the `CompactionEvent` SDK type); embedders
driving `ChatSession` through a duck-typed `SessionUI` are unaffected
(the classic `on_info` lines are restored for them — see Fixed).
- **Sampling knobs (temperature, reasoning effort) now ride one assignment
scheme: per-model alias value → operator-stored global setting → the
model definition's declared default (effort only) → field omitted.**
Turnstone previously manufactured values onto every unconfigured
request — a hidden `temperature: 0.5` and a `reasoning_effort: "medium"`
baked in at three layers — overriding serving-side defaults like a vLLM
model's `generation_config`. Unconfigured installs now send neither
field and the inference engine's own defaults rule; `model.temperature`
is blank by default ("inherit each model's own default") and
`model.reasoning_effort` defaults to the empty "inherit" choice. The
per-model → global resolution lives in one shared resolver used by the
session factories, the `/model` switch, and every `model_turn` lane, so
the same alias samples identically on every surface. CLI
`--temperature` / `--reasoning-effort` likewise default to inherit.
**Upgrade notes:**
- The empty (`""`) reasoning-effort choice changed meaning from
"explicitly disable thinking" to "inherit the model/serving default".
On local manual-thinking models (e.g. Qwen templates with
`enable_thinking`), a stored `""` previously sent
`enable_thinking: false`; it now sends nothing, so the template's own
default (often thinking ON) applies. Use **`none`** to actually
disable reasoning.
- Workstreams saved by earlier versions carry the old defaults
(`temperature=0.5`, `reasoning_effort=medium`) in their persisted
config and keep that exact behavior on resume; they pick up the new
inherit semantics the next time you change the model or a sampling
knob in that workstream. New workstreams inherit from the start.
### Removed
- **O-series and pre-5.4 GPT-5 rows dropped from the OpenAI capability
table.** `o1`, `o1-mini`, `o3`, `o3-mini`, `o3-pro`, `o4-mini`,
`gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `gpt-5-pro`, `gpt-5.1`,
`gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.2-pro`, and `gpt-5.3` no longer
have built-in capability rows — OpenAI has retired these model ids
from the API, so the rows described contracts no request can reach
anymore. The table floor is now `gpt-5.4`; the search-api and
audio/STT/TTS rows are unchanged. An alias still pinning a retired id
fails at OpenAI itself; any other unlisted commercial id resolves to
the generic commercial defaults (temperature sent, no declared
reasoning-effort vocabulary, 200K window) — declare the contract on
the model definition's capabilities JSON if you run one, or move to a
current model.
### Fixed
- **A cancelled judge, guard, or compaction call can now stop before its
request goes out (#972).** Previously it could not: `model_turn` refused
to *re-issue* an abandoned call after a mid-stream death, but nothing
checked before a first dispatch, so a call whose caller had already gone
away still sent — and the reply was discarded unread after the endpoint
had accepted the work. It now checks immediately before sending, so a
Stop observed by that point costs no request, and again on entry, so a
call already cancelled when it arrives also skips credential resolution.
Cancellation is cooperative, which bounds what that buys: a Stop only
saves the request if it lands before dispatch — sending is a moment, the
response streaming back is the rest of the call, and an abort arriving
then still meets a request in flight, closed in place exactly as before.
The window that did widen usefully is a delegated-auth alias whose token
mint blocks; a Stop during that mint now costs no request (though a mint
already under way still completes). What a stopped call saves is the
request, its prompt-side billing, and — on a capacity-bounded
self-hosted endpoint — a slot a live request wanted. Unchanged: the
interactive turn, which has its own pre-send cancellation check on a
different path, and the lanes that thread no cancellation handle
(attachment perception, title generation, web-fetch extraction,
sub-agents, optimizer, eval) — and web-fetch extraction deliberately
never will, since it runs on parallel tool threads where registering one
would clobber the main stream's.
- **Unmarked chain-of-thought no longer leaks into titles, summaries, or
web-fetch tool results (#940).** Some serving setups emit reasoning
inline with no tags and no `reasoning_content` at all — nothing any
parser can segregate. The bounded-artifact lanes (title, compaction,
web-fetch extraction) now ask the model for no reasoning instead:
the model definition's declared thinking toggle is pinned off for that
call — the same suppression transcription already used — and the
reasoning-effort channels (the relayed session knob, the definition's
default, the graded template key) are withheld with it, since an
effort value beside a pinned-off toggle re-requests the reasoning the
pin declined. A no-op on backends that segregate reasoning
server-side. Title generation additionally stopped trusting line
position: it takes the last line that reads as a title (within the
word cap and ending in a word character, so explanation sentences,
sign-offs, and reasoning headings lose in any script) rather than the
first non-empty line, which unmarked reasoning turned into titles
like "Thinking Process:".
- **A think tag split across a reasoning delta now reassembles.** The
non-streaming drain closes content runs at interleaving signals; a
partial-tag tail is carried across reasoning-delta boundaries (a
reasoning delta cannot terminate a tag) so the tag is consumed instead
of its halves passing through as visible content. Tool-call boundaries
still flush — no tag spans a tool call.
- **Streaming consumers follow the ACTIVE model's capabilities.** The
interactive tag-scan posture and the drain's scan gate now read the
capabilities of the lane that owns the stream being consumed (fallback
walks included) instead of the session's primary alias.
- **Notification bodies no longer fuse multi-block answers.** `Turn.text`
joins text blocks with a newline; a final assistant turn stored as
multiple text blocks previously concatenated the last word of one
block to the first word of the next in completion notifications and
every other flattened read.
- **String-typed boolean capability overrides coerce instead of
truthiness-flipping.** A hand-edited `"false"`/`"0"` in a model
definition's capabilities JSON now means false; unrecognized values
drop the key and keep the field's default.
- **Inline `<think>`/`<reasoning>` blocks no longer leak into drained
results (#965, #940).** On servers without a reasoning parser
(parserless vLLM/llama.cpp, LM Studio, bare gateways), reasoning
arrives as literal tags inside content; segregation now happens once
at the drain seam, so web-fetch tool results, sub-agent syntheses,
judge verdicts, titles, summaries, and optimizer prompts receive
tag-free content and the extracted reasoning rides the native lane.
Two behavior notes: a web-fetch extraction whose whole response was
reasoning now returns an explicit `Error: extraction returned no
answer` tool result (previously the raw reasoning text persisted as a
successful result and was replayed every following turn), and a
mismatched-vocabulary close tag (`<think>…</reasoning>`) now closes
the block — matching the interactive lane's long-standing rule —
where the old per-lane strips treated it as unterminated.
- **A transport failure mid-generation no longer kills the interactive
turn (#937).** A wire death during body streaming (TLS record failure,
connection reset — `httpx.ReadError` and kin) surfaces after the
request has already returned its stream handle, so neither the SDK's
request retries nor the creation-time retry ladder ever saw it: the
turn died with a bare `ReadError: …`, the partial output was
discarded, and nothing was logged. The interactive loop now normalizes
mid-body transport deaths exactly like the single-shot lanes and
re-issues the turn (bounded, cancel-aware, exponential backoff),
finalizing the dead attempt across every UI surface first so retried
text never double-renders (web transcript, CLI markdown fences,
Slack/Discord streamed messages). Before re-creating the stream the
session re-resolves its registry binding, so a concurrent model-registry
reload that closed the old client cannot turn the retry into a
misleading closed-client error. On exhaustion the surfaced error names
the provider, endpoint, and model with a stream-death message instead
of a bare exception string, and every fatal turn now leaves a
`session.fatal.recorded` log line (INFO for a user Ctrl-C, ERROR
otherwise).
- **A failed worker-thread spawn no longer wedges the workstream — at
either spawn site — and never masquerades as success.** If
`Thread.start()` itself raised (thread exhaustion, out-of-memory), the
dispatcher had already claimed the worker slot but the flag's only
clearer lived in the never-started thread — the workstream looked idle
forever while every subsequent message queued behind a worker that
didn't exist, until an operator force-cancel. The claim is now rolled
back under the lock and the error propagates, so the workstream is
dispatchable again as soon as resources recover. Affected every
dispatch path (sends, wakes, retries, deferred-send drain, init). The
same failure at the deferred-send drain's own spawn rolls back the
just-accepted entry and answers the retryable `queue_full` (previously
a 500 landed *after* the entry was registered — an invisible,
unretractable phantom that later dispatched as duplicate turns), and a
`/command` whose worker never spawned now answers **503**
`{"status": "error"}` instead of the generic 200 ok that told SDK
callers their `/clear` or `/resume` had applied.
- **Manual `/compact` from the web UI: no phantom user turn, no frozen
server, cancellable.** A slash command typed into the web composer no
longer renders as a user chat bubble (it echoes as a distinct command
chip — commands aren't conversation turns and were never persisted as
such). `/compact` itself now dispatches onto the workstream's worker
slot instead of running inline on the server's event loop — previously a
long compaction froze every SSE stream on the node for its whole
duration, which is also why its own progress only ever arrived as one
burst after the fact. The manual path carries `send()`'s full generation
discipline (`compact_now()`): a force-abandoned compaction goes stale
instead of swapping history under a successor turn — and retires at its
next checkpoint instead of running out its remaining summary calls,
with its late lifecycle events fenced off (`compaction_id` on every
event, `superseded` on end events — both in the SDKs) so they can't
animate, tear down, re-title, or falsely narrate a successor's card or
activity pill; a cancel aimed at it is consumed on exit (previously it
bricked every `/compact` retry until the next message); a Stop click on
an idle session can't pre-abort the next compaction; a Stop that lands
in the completion tail — after the last cancel check, or during a retry
backoff (which now aborts immediately instead of sleeping it out) — is
honored rather than silently eaten; and Stop now aborts the in-flight
summary HTTP call itself (the compaction lane registers its stream in
the same abort seam the main loop uses), so cancelling a compaction is
immediate instead of waiting out a model call.
- **Sends during a command window are deferred, ordered, bounded, and
honestly rendered — never silently truncated or lost.** Messages sent
while any slash command holds the worker slot are **deferred**: answered
`{"status": "queued", "msg_id"}` immediately and dispatched as ordinary
full-fidelity sends (attachments and sender identity included) when the
command finishes — never routed through the mid-turn interjection
queue, whose semantics are turn-shaped: previously a send during a
manual `/compact` was silently truncated to 2,000 characters, a second
participant in a shared workstream was locked out with a misleading
"another participant's turn" 409 for the whole compaction, and a
message queued across a `/resume`/`/new` could be answered into the
post-swap workstream. Because the response is immediate,
timeout-bounded callers — the coordinator's `send_message`, the console
proxy, SDKs, anything behind a stock reverse proxy — can no longer lose
a message to a multi-minute command window; the deferred send is
retractable until dispatch via the same `DELETE .../send` used for
queued interjections (node-local, in-memory — the API reference
documents the at-most-once durability contract). Deferred responses
carry `"deferred": true`; the pending list is the **order authority**
(a fresh send — or a coordinator dispatch, or a queued-nudge wake —
lines up behind acknowledged entries instead of overtaking them, with
the two-term barrier defined once on the workstream so the wake gate
also honors a claimed entry whose dispatch is mid-flight, and the gate
re-arms at the drain's exit even when everything pending was
retracted); acceptance is **bounded** (10 pending per workstream — the
interjection queue's own backpressure contract; the 11th answers the
retryable `queue_full` instead of pinning attachment bytes without
limit and then running one unattended turn per entry); a dispatch
crash re-queues the entry instead of eating an acknowledged message,
and a drain thread that fails to *start* rolls the acceptance back and
answers `queue_full` rather than parking a phantom the client can
neither see nor retract; each dispatch emits a pane-tier
`message_dispatched` event (`folded: true` for interjection fold-ins)
so queued-bubble UI keeps its retract affordance exactly until the
message truly leaves — including when the send was accepted by a pane
that believed the workstream idle, which now renders a real queued
chip instead of a sent-looking bubble, releases the composer (a
deferred send has no running worker to wait on), and cleans up fully
when the send is refused or the chip retracted instead of stranding
the pane in Stop mode. Dismissing a queued bubble — interjection or
deferred — is a server-confirmed `DELETE`, and retracting a deferred
send that carried attachments tells the user they were discarded
instead of silently expiring them.
- **Slash commands hold the worker slot with a loud contract.**
A `/compact` raced against an in-flight turn is refused with an
explicit busy response. Every other slash command runs through the same
worker slot too — mutual exclusion against sends, a running compaction,
and each other, with a busy answer replacing the old silent interleave —
while the endpoint still awaits quick commands' completion off-loop
(without parking an executor thread per request); the post-command pane
refreshes (`clear_ui` after `/clear`/`/new`/`/resume`, the
workstream-name sync) ride the worker itself, so a command that
outlives the endpoint's 25s response backstop still refreshes every
pane on completion (the backstop sits under the console proxy's 30s
client timeout so the degraded `running` answer can actually traverse
a proxied pane, which now surfaces it instead of silence; the
`/command` response contract — `ok` / `running`, with busy refusals
answering a loud HTTP 409 rather than a silent 200 — is now documented
in the API reference and the OpenAPI spec).
- **Compaction status stays truthful across every UI surface.** Manual
compaction
success also refreshes the status line/context pill immediately (parity
with auto-compaction), compaction failures keep feeding the typed
`error` event and the node error counter (while a CLI Ctrl-C reports as
cancelled, not a failure), one Stop prints one notice (a cancelled
auto-compaction no longer stacks "Compaction cancelled." on top of
send's own "[Generation cancelled]"), the workstream activity pill
shows "Compacting context…" for the whole summarize phase, restores
cleanly afterwards, and can no longer be stranded by a force-stopped
compaction (a new turn's generation claim breaks a stale latch). Every
retry backoff on the session (stream retries, task agents, notify
delivery, compaction) now aborts immediately on Stop via one shared
cancel-aware helper instead of sleeping out its exponential delay.
- **Compaction failures report exactly once, to the right owner.** A
compaction failure reports
exactly once (auto-compaction errors defer to the turn's fatal handler
instead of doubling the red row and the error metric), failed-end
notice suppression is computed once by the emitter (a `notice` bool on
the end event — in the SDKs — replaces hand-synced client policy), and
a manual `/compact` failure no longer crashes the CLI REPL. `/compact`
on a workstream showing the `error` badge restores the badge on exit
instead of stamping `idle` over it (the compaction neither retried nor
resolved the failed turn). A force-cancelled initial send that
completes late still delivers its scheduled-run completion
notification (the only completion signal unattended workstreams have);
the other post-command pane refreshes and error notices remain
owner-guarded, so a force-cancelled wedged command that unwedges late
can't wipe panes or inject stray notices into a successor turn.
- **Pre-1.8 embedder UIs keep their compaction lines.** Embedders
driving `ChatSession` with a pre-1.8 duck-typed `SessionUI`
(no `on_compaction` hook) get the classic `on_info` compaction lines
back — threshold notice, `part k/N`, retry waits, token delta +
summary box — instead of silent history swaps. (See the breaking
event-contract note under **Changed** for SSE/SDK clients.)
- **Static MCP servers: a pushed catalog change no longer wedges the shared
session (#839).** The static-path `*/list_changed` handler awaited its
catalog refresh inline in the SDK's receive loop, but the refresh's own
request can only be answered by that (now parked) loop — the refresh never
completed, and every user's in-flight calls on the shared per-node session
stalled behind it, unbounded, until the health loop's ping timeout tore the
transport down (which was also the only way the changed catalog ever
landed). Push refreshes now run as spawned tasks — debounced, coalesced per
(server, kind), bounded by the connect timeout, and serialized on the
per-server connect lock — and the manual and post-reconnect refreshes
publish under that same lock, so a slower publisher can no longer land a
staler catalog over a fresher one. Every teardown path now also clears the
notification debounce stamp, so a reconnected server's first push refreshes
immediately. Push-refresh debouncing is now per (server, kind) on BOTH the
static and per-user pool paths — a tools push no longer swallows a prompts
push arriving in the same 5-second window. A change genuinely lost to the
debounce window (a same-kind push landing after the prior refresh finished,
which the server will never re-announce) is recovered by an automatic
health-tick retry rather than staying invisible until an unrelated push or
a reconnect. The resource-refresh fan-out on both paths no longer orphans
its sibling list call when one of the pair fails fast — the real error
surfaces immediately (not masked as a 30-second timeout) and the surviving
sibling is cancelled and reaped, under a bounded grace, inside the scope. A
push refresh that fails while the connection stays up is likewise retried on
the next health-loop tick until one completes — previously a single
transient blip left the shared catalog stale for every user on the node
until an operator intervened. An operator `/mcp refresh` no longer parks
behind a busy per-server connect lock (a slow reconnect attempt could eat
the whole 30-second refresh budget and fail the pass for every healthy
server behind it) — the busy server is skipped on both the connected and
disconnected branches, reported distinctly as "skipped" rather than as a
false "no changes", the skip arms the automatic retry, and a
force-reconnect drops the session up front so queued push refreshes can't
starve it. Static-path resource and prompt catalogs are now size-capped
like the pool path's (and like static tools) at discovery and on every
refresh, so a misbehaving server's push can't balloon the node's merged
catalogs. Deleting or reconfiguring a server can no longer leave it
half-removed: the config removal and all cleanup are serialized under the
connect lock (a cancelled removal completes its cleanup rather than
stranding a live session and published catalog with the config already
gone), and `reconcile_sync` retries a removal that timed out instead of
marking it done — previously a DB-driven delete of a busy server could be a
silent, permanent no-op until process restart. A refresh outcome now
threads consistently to every operator surface off one source of truth
(the per-server `last_refresh_outcome`): a busy-skip and a genuine failure
are each reported distinctly from a real "no changes" — `/mcp refresh`
prints "skipped" or "failed" rather than a false "no changes", and the
node-internal refresh endpoint returns `202 skipped` instead of a
misleading `200 ok` for a refresh that never ran. A single-kind push
refresh no longer paints the whole server healthy: because the
error/outcome state is server-scoped, a successful tools push while the
prompts catalog is still broken (or vice versa) no longer clears the
failure — only a full refresh pass declares "ok".
- **OpenAI Responses streaming: truncated and refused responses no longer
vanish.** A response that hit `max_output_tokens` terminates the stream
with `response.incomplete`, which the stream consumer did not handle —
the turn was mislabeled `finish_reason: stop` and its final usage and
collected output items were dropped. Refusal parts had no streaming
handler at all, so a refusal rendered as empty content instead of the
`[Refused: …]` text the non-streaming path produced. Both now match:
truncation maps to `length` with usage/items intact, refusals render
in content. Applies to the chat loop and every drained single-shot
lane (#831).
- **task_agent: sub-tool ids no longer alias across a local model's reused
ids.** A local model that reissues per-response sequential tool-call ids
(`call_0` every turn) made two of a task agent's steps share one id — the
live card collapsed both onto one DOM row while `/history` recall kept them
apart, so the two views disagreed. Sub-tool ids are now minted
`{parent}::r{run}s{step}::{id}`, unique within the session (across an
agent's turns and across concurrent or sequential runs), and that one id
keys the nesting registry, the live rows, recall, and the cancel ledger.
On the wire the agent's self-built history carries the provider's own ids,
restored from the mint map (see the reasoning-lane entry under Added), and
malformed tool-call arguments are legalized the same way the main loop's
wire prep does.
- **bash tool: never hang on a backgrounded child.** A command that left a
long-lived process running (`server &`, a daemon) could wedge the whole
workstream forever — the tool read stdout/stderr to EOF, which never arrived
because the child inherited the pipe, and the timeout watchdog bailed once the
foreground `bash` had exited. The tool now waits on the tracked process
(bounded by the tool timeout) and terminates its whole process group on
return, so the call always completes. Undecodable output is preserved
(`errors="replace"`) instead of being dropped as a spurious error.
- **Behavior change:** a process the command backgrounds no longer survives
the call — nothing persists across bash invocations. (First-class
"run this in the background" support landed separately — see
`run_in_background` under Added.)
## [1.7.3]
A small feature and maintenance patch for the 1.7 line. No schema migrations
and no new configuration knobs.
### Added
- **OpenAI GPT-5.6 (Sol/Terra/Luna) support** — the Responses provider
understands the GPT-5.6 family: the `reasoning.mode` control, the new
`max` effort tier, and `text.verbosity`, with golden wire payloads pinning
the request shapes. The `openai` dependency floor moves to `>=2.44`.
### Changed
- **Engineer base prompt hardened with process discipline** — the default
base prompt for non-coordinator sessions now works in phases scaled to the
size of the change, defaults to red-green for testable work, scopes to the
smallest sufficient diff, stops to report after repeated failed attempts
instead of thrashing, reports only observed results, and delegates
exploration to `task_agent`. Persona prompts freeze into the workstream
stamp at creation, so this reaches new workstreams only.
### Fixed
- **Unknown reasoning-mode warnings name the allowed modes** — a model
definition with an unrecognized reasoning mode now logs the valid options
instead of leaving the operator to guess.
### Documentation
- **HYPOTHESIS.md / PRIMER.md** — the control normal form is tightened and
the factored Q_E reading is carried into the glossary; the plain-language
PRIMER stays in sync.
## [1.7.2]
A feature-bearing patch for the 1.7 line. Rather than hold this work for the
larger 1.8 churn, the fixes and the smaller features that had already
stabilised on `main` are rolled into the stable line now: a rich preview
pane, persona/project settings on scheduled tasks, and a batch of streaming,
rendering, and nudge-delivery hardening.
> **⚠️ Before upgrading:** 1.7.2 adds Alembic migration `066`, applied
> automatically on first start. It adds two `Text NOT NULL DEFAULT ''`
> columns (`persona`, `project_id`) to the `scheduled_tasks` table; existing
> rows migrate to the empty default, which is byte-identical to pre-066
> dispatch behaviour. The change is additive and reversible, but — as always
> — back up your storage before upgrading (`pg_dump` for PostgreSQL; copy the
> database file for SQLite).
### Added
- **Rich preview pane + `open_preview` tool** — a workstream can now open a
rendered preview (HTML, Markdown, and other kinds) in a pane beside the
conversation via the new `open_preview` tool. Guarded fetches stream under
a byte budget whose ceiling tracks the widest per-kind cap, preview blob
ids are salted, and a preflight probe handles legacy charsets and a
remote-assets opt-in. See `docs/tools.md`.
- **`allow_private_network` opt-in for `web_fetch` / `open_preview`** —
private-address fetch and preview targets stay blocked by default; an
operator can opt a workstream in through the settings registry when a
private endpoint is genuinely intended. (Distinct from the 1.7.1 `[oidc]`
flag of the same name, which governs identity-provider discovery.)
- **Persona + project settings on scheduled tasks** (migration `066`) — a
scheduled task can now pin the **persona** and **project** of the
workstream it dispatches, matching the levers a manually-created workstream
already carries. Both default to empty (kind-default persona / no project),
so existing schedules dispatch exactly as before.
### Fixed
- **Streaming fast-path overflow recovery** — fast-stream tokens are now
batched and overflowed SSE listeners recover instead of stalling (and
`connectSSE` no longer opens into a hidden background tab). The same
overflow-recovery companions were carried to the coordinator pane, so a
coordinator watching many children recovers dropped listeners the same way
the live-session view does.
- **Renderer containment** — markdown sentinel-forgery and recursive-frame
content loss are contained, and an indented fence close no longer drags its
indent into the enclosed code content.
- **Idle nudge / wake delivery** — nudge and wake delivery is hardened across
session eviction, cancellation, and identity rebinds; the wake gate now
requires a real nudge queue, refused wakes are logged, and
`initial_message_status` is typed as a closed enum on the wire.
- **`web_fetch` extraction inherits model settings** — the completion that
extracts content from a fetched page now inherits the workstream's model
settings instead of falling back to defaults.
- **UI panes** — ephemeral panes close on split-dismiss instead of orphaning
a tab, and an unsplit skips the redundant refresh after an ephemeral pane
closes.
- **Shared code-highlight CSS** — renderer-output CSS is shared so the console
and coordinator panes highlight code identically.
### Security
- **`Content-Disposition` filenames made wire-safe** — download filenames
derived from user-controlled text are sanitised (latin-1- and
control-char-safe, quoting-safe) before they reach the `Content-Disposition`
response header, including the fallback path.
### Documentation
- **HYPOTHESIS.md: daemons + the outer loop, plus a plain-language PRIMER** —
the harness north-star document gains its daemon / outer-loop treatment and
a new top-level `PRIMER.md`.
## [1.7.1]
A maintenance and hardening patch for the 1.7 line. No schema migrations;
the credential-redaction work below is additive and needs no configuration
change. The one new operator-facing knob is the opt-in `[oidc]
allow_private_network` flag (default off).
### Security
- **Credential redaction hardened across the tool-call surface** — the
redactor that scrubs secrets from tool arguments and log previews was
reworked on both the backend and the browser to close several leak paths
and to fix false-positive and performance issues. Malformed tool-call
arguments are now legalised before they reach the wire; the tool-args log
preview scrubs credentials and control characters; and the coordinator's
tool-call cards gain a matching client-side redaction pass so the JS and
backend redactors stay at parity. Pattern coverage now includes
`secret_access_key` / `aws_secret_access_key` multi-segment keys, bare
`token=` / `key=` forms (guarded by a negative lookbehind to avoid
false positives), and SQLAlchemy `+driver`-qualified connection-string
schemes matched case-insensitively.
- **OIDC SSRF guard: `[oidc] allow_private_network` opt-in** — self-hosted
identity providers on private networks can now be reached by setting
`allow_private_network = true` under `[oidc]` (default off; the MCP OAuth
path stays strict). Rejections of discovered endpoints carry the opt-in
hint so the misconfiguration is self-explanatory. See `docs/oidc.md`.
### Added
- **Persona discoverability + forgiving name resolution** — personas are
now discoverable by agents, and persona-name resolution tolerates
case/whitespace variation; a not-found resolution reports the offending
input verbatim instead of a bare error.
### Fixed
- **MCP transport lifecycles routed through per-entry owner tasks**
(#787/#788) — static and pooled MCP transport lifecycles are now driven
by per-server / per-entry owner tasks, with a hardened disarm-sweep loop
guard and targeted exception handling in place of a broad `BaseException`
arm, so a dying transport can no longer spin the CPU or strand delivery.
- **Client-construction failures surface as misconfiguration, not raw
500s** — a model whose client cannot be constructed now reports a factory
misconfiguration, and the raw exception text is kept out of the resulting
503 response.
- **Postgres history search survives oversized rows** — a conversation row
exceeding Postgres' full-text limits no longer aborts history search.
- **Agent-tool render is idempotent** — tool rendering no longer deep-copies
a tool definition until a description actually changes, so no-persona
sessions share the tool constant (correctness plus a hot-path allocation
win).
- **Private-project workstream visibility scoped to members** — workstreams
in a private project are visible to project members only, not to every
admin; coordinator tenancy checks now use request-scoped storage.
- **Pane hotkeys work off macOS and match across surfaces** — the pane
keyboard shortcuts no longer collide with browser accelerators on
non-macOS platforms and behave consistently across surfaces.
## [1.7.0]
The headline of the 1.7 line is **Personas** — operator-authored control
over how each workstream composes its system message and capability
envelope. The rest of the release hardens the pieces a persona leans on:
concurrent approvals, cross-provider reasoning-effort control, cooperative
compaction, multi-user session safety, and MCP resilience for unattended
work.
> **⚠️ Before upgrading:** 1.7.0 adds Alembic migrations `062``065`,
> applied automatically on first start (projects, personas, and two
> smaller schema tidy-ups). Migration `063` creates the `personas` table
> with its six seed personas and converts existing `creative_mode`
> workstreams to the `writer` persona in place. The changes are additive
> to your conversation data, but — as always — back up your storage before
> upgrading (`pg_dump` for PostgreSQL; copy the database file for SQLite).
**Breaking changes at a glance** (details in the sections below): the
`/creative` REPL toggle removed (replaced by the `writer` persona), the
`turnstone-bootstrap` entry point renamed to `turnstone-doctor`, and the
approval-status API/SDK field `pending_approval_details` changed from a
single object to a list (one entry per concurrent approval cycle).
### Added
- **Personas** (#683) — a named, reusable bundle attached to a workstream
at creation, controlling system-message composition and the capability
envelope via exactly four levers: base-prompt override, tool visibility
set, MCP on/off, and memory on/off. The persona is resolved once and
snapshotted into `workstream_config`; editing or archiving a persona
never changes an existing workstream. Six seed personas ship with
migration `063` (`engineer` and `orchestrator` are the per-kind
defaults with no overrides, so zero-touch behavior is unchanged;
`scribe`, `researcher`, `writer`, and `executive` are curated
envelopes). Selectable on every creation surface (web pickers, the
create API/SDKs, coordinator `spawn_workstream` / `spawn_batch`, and
`turnstone --persona <name>`); authored in the console's new
Governance → Personas tab (`persona.{create,read,write}` perms,
archive-only lifecycle). See `docs/personas.md`.
- **Projects — governed resource containers** (#724) — group workstreams
and their resources under a project (migration `062`), with
project-scoped memory, a per-project resources view, a project column on
the saved list, and server-enforced private-project workstream
visibility.
- **Task-agent sub-harness** (#732) — a spawned task agent now runs on its
own Turn-IR sub-harness with parent-tagged step events: its sub-tool
steps nest inside an expandable card in the parent trajectory, its
sub-trajectory is recallable, and each agent gets read isolation from
its siblings.
- **MCP static-server autonomous reconnect** (#768) — statically
configured MCP servers are now kept live by a health loop
(capped-jittered backoff, ping-based liveness) instead of silently
staying dead after the first transport drop.
- **Attachments — capability-gated client-side fallback** — when the
active model can't natively handle an attachment, the client degrades
gracefully (PDF → extracted text, audio → transcript) instead of
failing the turn.
- **Eval measurement / optimizer split** (#763, #765) — `turnstone-eval`
is now a measure-only substrate with the prompt optimizer factored out,
plus a new skill-adherence measurement mode.
- **Deployment examples** — a vLLM + LiteLLM unified-memory inference
example showing a 3-model co-resident stack with an HF loader (#686,
#688), and an Altair + `vl-convert-python` visualization stack (#685).
- **Concurrent approvals and a long-session frontend overhaul** (#754,
#755, #773, #775) — the live-session frontend was reworked for long
runs (the pipeline is wedge-proofed and its hot paths de-O(N)'d), and on
top of it a workstream can now hold more than one tool call awaiting
approval at a time. Each parallel batch gets its own approval cycle,
with one card per pending call in the interactive and coordinator UIs,
cycle-keyed tracking in Slack and Discord, and cycle-routed resolution
across the server/console/SDK APIs; sub-agent tool gates run the
intent-judge pipeline as their own generation. The send button no longer
sticks disabled after a batch resolves — orphaned approval cycles are
pruned and the app is the sole owner of the button state.
*(BREAKING: the `pending_approval_details` field is now a list, oldest
first.)*
- **Reasoning-effort control on every provider lane** (#771, #774) — the
session effort knob now reaches local backends too: it drives
`chat_template_kwargs` on the anthropic-compatible and openai-compatible
lanes and threads through to Gemini and xAI, alongside the commercial
providers that handle effort natively. The console surfaces each model's
effective effort ladder in plain words and adds an always-on
thinking-mode option to the model form. Effort snapping is ordinal —
it rounds up and caps at the model's ceiling rather than silently
dropping.
### Changed
- **Skills are capability-context, not identity** (#762) — a task agent's
identity now comes from its persona; an applied skill's body is demoted
to capability context and moved out of the identity system message.
Skill-body substitution is unified across every invocation context so
the same skill renders identically whether loaded interactively, by the
model, or inside a sub-agent.
- **`turnstone-doctor` replaces `turnstone-bootstrap`** (#718)
*(BREAKING)* — the setup/diagnostics entry point is renamed; update any
scripts or service units that invoke `turnstone-bootstrap`.
- **Honest cancellation dispositions** — cancelled or timed-out
side-effecting tools now report an `UNKNOWN` disposition rather than a
flat failure, tool dispositions are typed (not just prose), and a
coordinator cancel propagates down the sub-tree.
- **Multi-user shared-workstream context** (#750) — in a shared
workstream, send is gated to the acting participant while a turn is in
flight (both the interactive and coordinator surfaces), cross-user
mid-turn interjections are blocked, and shared-workstream state plus
fork sender attribution are now durable.
- **Cooperative compaction** (#730) — the context budget is anchored to
the provider's true capacity, the summary call is chunked so it can't
overflow, and the active plan and the outstanding ask are carried across
compaction verbatim. The `recall` tool is scoped to the compacted-away
past.
- **Intent judge sees the full tool arguments** (#760) — the judge's
argument projection is no longer narrowed, so it stops issuing confident
false denials on a partial view. The output-guard judge sources its real
context window, and `context_window = 0` in `config.toml` now means
auto-detect.
### Fixed
- **Compaction resume hardening** (#731) — checkpoint markers are
persisted so resume rehydration is bounded, context-overflow on resume
is recovered across providers, and a recognized rate-limit is no longer
misclassified as context overflow.
- **MCP unattended-work resilience** (#706, #742, #767) — dead-transport
handling is completed, consented OAuth (OBO) tokens are refreshed
proactively so autonomous runs don't strand on an expired grant, the
Entra ID on-behalf-of impersonation flow blockers are closed (migration
`065` adds the OIDC `oid`), and OAuth refresh failures are classified so
a transient blip never revokes consent nor a dead grant strands the
user.
- **Memory writes** (#735) — save/update is a single atomic upsert, and
writing a memory no longer recomposes the system prefix mid-session.
### Removed
- **`/creative` removed** *(BREAKING)* — subsumed by the Personas feature
above: the REPL toggle (and its tab completion) is gone, and the
`writer` seed persona replaces it — start a session with
`turnstone --persona writer` or pick *Writer* in the web
pickers. Unlike the old fork, the writer persona composes the full
system message, so session context and mandatory prompt policies now
apply to prose-only sessions too. The `creative_mode` key in
`workstream_config` is no longer read or written. Migration `063`
converts existing creative-mode workstreams to the `writer` persona
automatically, so they resume as writing sessions rather than as
legacy defaults.
### Security
- **High-risk skill activation is gated** (#762) — a model-initiated load
of a `high`- or `critical`-risk skill is gated and fails closed when the
backing storage is unavailable, so an untrusted turn can't silently
pull in a dangerous capability.
- **Dependency security floors** — `cryptography` and `starlette` are
pinned to security-fixed minimums.
- **CI publish hardening** — the vendored-JS dispatch path refuses fork
PRs, and `workflow_run` publishing is gated to same-repo tag pushes, so
a fork can't trigger a release build.
## [1.6.0]
The first stable release of the 1.6 line — and the first under Apache 2.0.
+5
View File
@@ -8,6 +8,11 @@ The following people have contributed code to the project — thank you:
- Burhan ([@Burhan-Q](https://github.com/Burhan-Q))
- chrismuzyn ([@chrismuzyn](https://github.com/chrismuzyn))
- daoxley ([@daoxley](https://github.com/daoxley))
- metaclassing ([@metaclassing](https://github.com/metaclassing))
- posixpositive ([@bensonjohnson](https://github.com/bensonjohnson))
- Robert DeAngelis ([@OriginalOrangeXD](https://github.com/OriginalOrangeXD))
- Sanjay Santhanam ([@Sanjays2402](https://github.com/Sanjays2402))
- Stefano Maffeis ([@lesbass](https://github.com/lesbass))
- William ([@sillyWillieBilly](https://github.com/sillyWillieBilly))
- [@BlackMyrmidon](https://github.com/BlackMyrmidon)
- [@pizzaandcheese](https://github.com/pizzaandcheese)
+7 -3
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.26 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.12.1 /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
@@ -55,13 +55,17 @@ COPY docker/healthcheck.py /usr/local/bin/healthcheck.py
# Entrypoint script — runs migrations before starting
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Data directory — SQLite DB is created in CWD
WORKDIR /data
RUN chown turnstone:turnstone /data
# Workspace mount point — bind-mount a host directory here
# Workspace mount point — bind-mount a host directory here. The env var
# surfaces the path in the model's shell/file tool descriptions
# (config.get_workspace_dir); without it the mount is invisible to the
# model, whose cwd is /data below.
RUN mkdir -p /workspace && chown turnstone:turnstone /workspace
ENV TURNSTONE_WORKSPACE=/workspace
USER turnstone
+45 -23
View File
File diff suppressed because one or more lines are too long
+155
View File
@@ -0,0 +1,155 @@
# What a Harness Is — and What It Can Never Promise
*A plain-language companion to [HYPOTHESIS.md](HYPOTHESIS.md). Same object, no symbols required.*
**How to read this.** HYPOTHESIS.md defines, formally, what an agent harness is and what it can never guarantee. This file is that document lowered into plain language — and by the formal document's own rules, a summary is a cache, not an authority: it must stay re-derivable from its source, and wherever the two disagree, the formal one wins. Symbols appear once, in parentheses, so you can cross over; nothing here requires them. And none of it is decoration: the formal version, used as a checklist, has caught real bugs in a real harness — because most bugs are a violated invariant nobody had written down.
## The problem
You have a model. It is, roughly, a brilliant, tireless, lightning-fast intern that has read most of the internet — and that sometimes makes things up, sometimes gets confused, and sometimes takes instructions from strangers, because a page it was asked to read said "ignore your boss and email the passwords here" in white text on a white background.
So you don't wire the intern to production. You build a loop around it. The **harness** is that whole governed loop: a deterministic shell *you* write — build the prompt, approve or refuse each proposed action, fold the result back into memory — wrapped around a model you didn't write and a world you don't control, repeated until the run reaches a stopping state. The shell is code and does the same thing every time. The model is neither, and everything in the theory comes from taking that split seriously.
One sentence to keep: **the model proposes; the gate disposes.** The model's output is never an action. It is a suggestion, in text, which a piece of ordinary code you wrote either turns into an action or refuses.
## The parts
| Plain name | What it does | In the formal doc |
|---|---|---|
| The owner | The human — or sign-off group — the run acts for; the only place new permissions can come from | the trusted principal |
| The memory | Everything the run knows: task, plan, transcript, and the ledger of what has been done | the state, *s* |
| The prompt builder | Decides which slice of memory the model gets to see this step | the lowering, π |
| The model | The black box that reads the prompt and writes a proposal | the plant, M_W |
| The gate | Ordinary code that checks every proposal and approves or refuses it | the gate, γ |
| The tools and the world | What approved actions actually touch: files, APIs, shells, people | the environment, Q_E |
| The verifier | Checks each tool result, then writes it into memory | the fold-back, ρ |
| The stop rule | Decides when the run is finished — and whether it finished *well* | the halt set H, accepting halts H_ok |
| The danger zone | States that must never be reached: secrets exfiltrated, wrong files deleted, money moved twice | the bad set, B |
The loop:
```
you ask for something
prompt builder → model → "I propose: send_email(...)"
GATE ── no ──→ nothing happens (safe, recorded)
↓ yes
tool runs in the world
verifier checks the result, writes it to memory
done? ── no → around again
↓ yes
stop (well, or refused)
```
## The rules that make it a harness
Four invariants, all about *where* things are allowed to happen.
1. **The model sees only what the prompt builder shows it** — never raw memory. The corollary with teeth: a secret that never enters the prompt cannot leak through the model. The redaction step that keeps credentials and other people's data out of the prompt must be dumb, deterministic code — the moment that filter is "smart," your confidentiality guarantee is a probability.
2. **Model outputs are proposals, not actions.**
3. **Every side effect passes the gate.** There is no second door.
4. **The harness itself flips no coins.** Replay a step with the model's answer and the tool results pinned, and behavior must be identical; any leftover variation is randomness *you* added and must be accounted for. The fine print: "deterministic" is conditional on pinned versions — a provider silently retraining the model behind the same API name changes the machine under you, and every dashboard number you collected dies with the version.
Notice what the rules don't say: they don't say the harness is *good*. A gate that approves everything satisfies rule 3 the way a lock that's always open satisfies "has a lock." The definition is a shape; the guarantees are what a particular harness *earns* inside it. Everything below is about what can be earned — and what can't.
And notice the symmetry between rules 1 and 3. There is exactly one door from your data into the model — what it may see — and exactly one door from the model into the world — what it may do. Nearly every security failure in these systems is one of those two doors with a hole in it: a secret lowered into a prompt that didn't need it, or a path from model text to a side effect that skipped the gate. Same bug, arrow flipped.
## Fail-closed, said precisely
"Fail-closed" gets used loosely. Here it means something exact: **nothing happens unless the gate said yes, and a refusal must itself be safe** — a refused proposal causes no side effect and leaves the run somewhere sane, which may be "stopped, having declined." The run is allowed to *say so*: a templated status message written by the shell is the shell speaking, not the model, and needs no gate. Failed runs don't have to die silent.
Three consequences people miss:
**Reads are not free.** A read-only call can smuggle instructions *in* (the fetched page is attacker-controlled) or secrets *out* (the URL it fetches can encode the payload). The gate approves calls, not just writes.
**Validation must not act.** A "validator" that resolves a URL, expands a template that fires a webhook, or evaluates an argument has already acted — inside the check. The gate must be pure: it reads the proposal and the memory and outputs yes or no. If deciding requires touching the world, that touch is itself an action and goes through the gate.
**Anything irreversible is decided at the gate.** The verifier can reject a bad *result*; it cannot unsend the email. So the question "can we take this back, and until when?" is asked before execution — which means each tool declares, up front, how reversible its effects are, and the gate reads that declaration when it decides; the mark that comes back in the result record is confirmation for the books, not the gate's source — the gate needed the answer before the tool ever ran.
Two honest asterisks. First, the gate checks a snapshot: it approves against the world *as its memory describes it*, and the world can move between check and commit. For actions that race the world — spend against a balance, write against a row — the tool itself must bind check to commit (compare-and-swap), or you have a classic time-of-check/time-of-use hole. The gate decides; for those effects, the tool enforces. Second, a gate is only as binding as the authority behind the tools. A tool process holding standing credentials — a database connection with every grant, an environment full of long-lived secrets — doesn't need the model's proposal to act, and against it the gate's "no" is a decision with nothing enforcing it. **A gate in front of an omnipotent tool is a suggestion.** The fix is to make the approval *be* the key: each authorized action carries a short-lived credential scoped to exactly that action, that resource, that operation, so tools hold no standing power at all.
## Why you don't get a proof — and what you do instead
If you write a sort function, you can prove it sorts: the function is small and the spec is exact. A harness has neither luxury. The spec side fails first — the task arrives in natural language, and natural language is, in the compiler's sense, *all undefined behavior*: there is no formal standard for "what the user meant" to verify against. The mechanism side fails next — the model is billions of learned parameters, and nobody can hand you a compact argument for why they jointly do the right thing.
Here is the careful version, because "you can't prove it" overshoots. The quantity you would want — call it the *expected steps to done* from any situation — is perfectly well-defined; in principle it exists. The document's central conjecture is that, for a model of this size, any faithful writing-down of that quantity is roughly *model-sized*: the honest proof-object does not compress. Find a small one and the conjecture dies — the document lists that outcome, explicitly, among the ways it could be wrong.
So instead of proving, you measure. You pick a progress meter — plan depth shrinking, open obligations closing, budget burning at the expected rate — and you check, across many runs, that it goes downhill and that its stalls predict failure. Two disciplines keep the measurement honest. The number bounds the world you *sampled*, never the world an adversary will choose: a meter calibrated on friendly traffic says nothing about hostile traffic. And the meter is itself attack surface: if "is the agent making progress?" is judged by another model, an attacker who can bend your agent can bend your *measurement of it* first, hiding the divergence from the very dashboard built to catch it. A learned meter is part of the system under test, never a neutral instrument.
A measurement is a risk metric. A proof is a certificate. Keeping those two words apart is half of what this theory is for.
## Security: reach the goal, avoid the danger — and who may change the rules
Formally, security here is a *reach-avoid* problem: reach a good stop, never touch the danger zone, **while an adversary picks the worst tool outputs your setup permits**. That last clause is the formal home of prompt injection: injection isn't "the model misbehaved," it's the environment optimized to bend your loop — poisoned pages, malicious tool descriptions, crafted responses.
Two different numbers fall out here, and dashboards love to collapse them: *success* (reached an accepted end before anything went wrong — a safe refusal counts against it) and *safety* (never touched the danger zone — a safe refusal is perfectly safe). Track both. They move independently. And both are scored by your own stop rule — they count what the shell *declared* a success. Whether a declared success was actually *right* is a third, harder number that no dashboard inside the system can produce; only a judge outside the run — a test suite, an audit, ground truth — can.
The gate handles the visible half of injection: the model, freshly poisoned, proposes emailing your credentials somewhere, and the gate refuses — and injection or not, the action does not happen. But the deeper attack doesn't propose a bad action today. It rewrites *what the run believes its job is* — it edits the plan — and then every future action looks locally reasonable against a corrupted plan. So memory has to be partitioned: **data** (tool results, fetched pages, retrieved documents — content the world supplied) and **control** (the plan, the permissions, what is authorized next). The security claim is conditional on that partition holding: untrusted content lands in data, always. And "trust" is really two questions pointing opposite ways, which is worth keeping straight: *can this leak?* (a value is as secret as the most-secret thing that fed it — secrecy flows **upward**) and *can this boss us around?* (a value is as trustworthy as the least-trustworthy thing that fed it — authority flows **downward**). Untrusted content is safe as *data* precisely because the second question keeps it off the control side; a secret is kept out of the model by the first. Lowering either barrier on purpose — declassifying a secret, promoting data to trusted — is an explicit decision the owner makes, never a thing that happens by accident when two values are combined.
Which forces the question the theory has to answer: *somebody* must be able to write control mid-run, or no plan could ever be steered and no permission ever granted. The answer is a small hierarchy with a top the model can't reach. The simplest top is one owner — but it needn't be a single person: a two-person sign-off, a quorum, several authenticated people each holding different scopes all work equally well, because the one property that matters is the same for all of them — the thing that can grant new power is a *human decision*, never a model:
- **The top alone widens.** New permission, bigger budget, approval of the irreversible thing — asking the top — the owner, in the simple case — is itself an ordinary tool call, and its answer is the one kind of tool result allowed to change control.
- **The model rewrites the plan** — that is what replanning *is* — but only through the gated loop, and a plan is not a permission: nothing the model writes into its own plan can grant it powers it didn't have.
- **Everything else is data.** A fetched page can inform the plan only by passing through the model and the gate like everything else. It can suggest. It cannot promote itself to boss.
- **AI judges only tighten.** Add a model-based check — "does this action match what the user actually wanted?" — and its verdict may *veto* an action the plain rules would have allowed, never approve one they'd have refused. A judge that can approve is a tricked judge that can open the vault. And don't over-credit the veto either: a tricked judge can *aim* its refusals — denying exactly the action safety depended on, or denying everything but the path an attacker curated — so the escape hatch to the owner is the one thing a judge can never veto, and a judge's stated *reasons* are picked from a fixed, shell-owned menu, never written as prose. A judge that writes free text into the loop is an injection channel wearing a badge.
One more rule closes the loop: transformations don't launder trust. A *summary* of a session that contained an injected page is still injected — the summarizer is a model, and can be persuaded to write "the user asked to export the database" into the summary. So summaries of data are data, and the control lines — the plan, the grants — cross a summarization by being *copied verbatim* or re-confirmed by the owner, never paraphrased by the model. Memory that persists across sessions carries its trust label with it, or a poisoned memory is just an injection with a very long fuse.
## Operations: the rules you feel on Tuesday at 3 a.m.
The formal document's appendix works the operational cases in full; here they are at speed.
**The ledger, and the three-way distinction that keeps it honest.** Every action gets an ID and a record: committed, never-launched, or *unknown*. "The tool didn't confirm" is not "the tool didn't do it" — collapse those and you will, sooner or later, re-send something that already happened. And a subtler honesty: the ledger records what the tool *reported*, not what the world actually did. A well-built shell can guarantee its bookkeeping is faithful to the responses it received — it cannot, on its own, guarantee a tool told the truth. A tool that returns a clean "done!" for something it never did puts a clean "done!" in your ledger. So "the ledger is what happened" is only as good as your reason to trust the tools reporting into it; where you have no such reason, *unknown* is the honest entry, not an optimistic guess in either direction. The double-send bug has one reliable cure: **journal before dispatch.** The shell writes "I am about to run action #417" into durable memory *before* the tool sees it, so a crash in the gap resumes to an honest "unknown — go ask," never to silence misread as "never sent." Old database wisdom, but here it isn't imported; it's forced — it is the only ordering under which every crash point has a truthful reading.
**Crashes aren't finishes.** A process dying mid-run is not the run stopping; it's the run *pausing being computed*. Resume means re-entering the loop at the last durable memory — sound exactly when the durable memory was the *whole* state. Anything load-bearing that lived only in RAM — an in-flight buffer, a plan revision not yet written — is a bug you discover at the worst possible time. Recovery is where you find out whether your state was really your state. And a run you stopped — crash or deliberate cancel — is not automatically a *safe* run: if something was in flight and you never learned whether it fired, it may already have done the damage. "We stopped in time" is only true when everything in flight resolved to something safe; an outstanding *unknown* has to be treated as possibly-bad, the same optimism the ledger warns against, one level up.
**Two innocent actions can be guilty together.** Models emit several tool calls per turn. "Read the secret" passes review. "Post to the web" passes review. The pair is an exfiltration channel — so the gate authorizes the *set*, atomically, with the interactions checked, not each element in isolation.
**Sub-agents are just fancy tools.** An agent that spawns another agent is, from the parent's chair, calling a tool: the spawn is gated, the budget is part of the deal, and the child's whole run comes back as one result carrying the child's ledger. Two laws travel down the tree: budgets subdivide, and **authority only narrows** — a child holds at most a subset of its parent's permissions, and a child's request beyond those grants routes *up*, ultimately to the owner, because a parent inventing an approval it never held is the tricked-judge case wearing a manager's badge. A corollary worth framing: a *fully autonomous* run is one whose owner is unreachable — meaning the only channel that can ever widen anything is closed, and its permissions are frozen at launch. That is not a limitation of the theory. That is what the word "autonomous" costs.
**Keep the originals.** When the transcript outgrows the prompt and you summarize it down, deleting the original is an irreversible act against your own state — and irreversible acts are gate decisions, self-directed or not. Keep originals content-addressed; let the summary be an index, re-derivable, auditable. A summary you can check against its source is a note. A summary that replaced its source is a fait accompli.
## Robots that never clock out — and robots that assign their own work
Everything so far assumed a job that *ends*: you ask, the robot does it, you read the result. Two steps past that are where the interesting failures live, and they're the same idea one level bigger each time.
**The robot that never clocks out (a daemon).** A monitor, a coordinator, a service — it isn't supposed to finish; it's supposed to keep going, wake on events, do a bit of work, go back to waiting. The clean way to think about it: each wake-work-rest cycle is one ordinary run, and the daemon is just those runs chained end to end forever. That reframing is free — but it comes with a bill nobody likes. **Safety that's fine per cycle rots over many cycles.** A 99.99%-safe cycle sounds bulletproof; run it ten thousand times and you're at about a coin-flip of having touched the danger zone at least once. So a long-running robot's safety isn't a fixed wall, it's a slow leak — which means the antidote isn't a better wall, it's *scheduled resets*: the owner re-confirming, credentials rotating, memory getting audited and re-summarized against the originals. Housekeeping isn't housekeeping; it's the thing that keeps the safety math from decaying. And the slow-leak logic is exactly where slow attacks live — a poisoned note dropped into memory on Monday and read back into the plan on Friday is an injection with a long fuse. So the trust label on a piece of information has to survive across cycles, not just within one. One more wrinkle: a daemon drifts in and out of your reach. While you're around, it can escalate to you; while you're not, "escalate to the owner" isn't available — so the one thing it must always be able to do instead is *stop*. A robot that can be tricked into refusing everything, and can't reach you, had better be able to halt rather than be steered.
**The robot that assigns its own work (the loop).** Step back one more time. Above the robot that *does* a task sits a system that decides *which task is next* — scans the backlog, picks one, launches the robot at it, checks the result, remembers, fires again. This is the thing people mean in 2026 when they say they've stopped prompting their agents and started writing *loops* that prompt them: you design the assigner once, and it runs the doer for you while you sleep. The honest observation — and the reason this document bothers with it — is that the assigner is *not a new kind of thing*. It's the same harness, one level up: it has its own memory (the backlog), its own gate (**who let the loop refactor the auth module at 3 a.m.?**), its own verifier, and its own two walls. Every rule from the inner robot recurs on the outer one — including the uncomfortable ones. There's still no proof it stays out of trouble over a long night; there's only a measured progress meter, with the same catch that a *learned* meter can be fooled. And the origin story of the whole trend is the cautionary case in miniature: the famous first version was literally the same prompt in a `while` loop until the tests passed — which is the empty gate, the always-open lock, one level up. It works beautifully right up until the tests weren't checking the thing that mattered. The loop doesn't delete the hard problems. It moves them up a floor, where they're bigger and you're further away.
The pattern, if you want the whole thing in one line: *words, context, robot, loop* are four sizes of the same object, and every promise in this document lives in the whole assembled thing — never in any one layer by itself.
## The two walls
Two limits are structural. You don't fix them with a better harness; you design around them.
**The desk.** The model can hold only so much *in mind at once* — the context window. Files, databases, and search extend what it can *look up*, not what it can hold: every lookup still passes through the same small window to touch actual computation. The shell can page; the model cannot grow its desk. Tasks whose irreducible working set exceeds the desk don't fail loudly — they fail by forgetting the middle (the well-documented "lost in the middle" effect is this wall showing through the paint).
**The dictionary.** The model's knowledge is frozen into its parameters at training time — and the proof problem above is conjectured to live at that same scale: the certificate wouldn't fit anywhere smaller than the brain it certifies. The two walls trade against each other along the training-versus-inference axis — bigger dictionary or bigger desk — directionally, and at no clean exchange rate.
## How this could be wrong
This is a hypothesis, and it says out loud what would kill it. The tests, in plain terms:
- **The replay test.** Rerun with model answers and tool results pinned. Any leftover variation — timestamps, wall-clocks, and cache expiries are the classic leaks — falsifies "the harness adds no randomness" until accounted for.
- **The drop-a-variable test.** Remove something from memory; if behavior statistics shift, the memory wasn't complete. The crash-resume version of the same test: if resuming from saved state breaks, the saved state wasn't the state.
- **Does the meter mean anything?** If no reasonable progress meter's drift predicts real failures — across the natural families, not just one bad candidate — the whole "measure what you can't prove" program is empty.
- **The red-team test.** Swap sampled tool outputs for worst-case ones: injected pages, poisoned metadata, malformed replies. The design must survive the worst permitted world, not the average one.
- **Gates versus begging.** The theory predicts deterministic gating beats prompt-level pleading. If "please be careful" alone matches real gates on security outcomes, the controller-versus-model story is wrong.
- **The compression hunt.** Exhibit a compact, provably sound progress certificate for a frontier-scale model on a nontrivial task family, and the central conjecture falls — constructively.
- **The desk probe.** Take a task family with a *proven* memory floor — so "it needed the whole picture at once" is someone else's theorem, not our excuse — scale it past the window, and watch: the wall predicts a *ceiling*, not a cliff — past the boundary, a success rate that stays capped no matter how many retries you buy. A family solved reliably out there, without new shell tricks for splitting the work, kills the wall.
## Who else landed here
The formal document keeps three honesty tiers. **Borrowed**: real theorems, cited — the drift and stopping-time mathematics is classical, and the very architecture of a deterministic supervisor gating a plant it didn't author is 1987 control theory; the shape is older than the web. **Ours**: the modeling choices and the conjectures — the walls, the incompressibility claim, the design rules — organizing principles, not results. **Corroborated**: pieces of the same object reached independently by people who never saw this framing — capability-security work isolating control flow from untrusted data (CaMeL), reinforcement-learning "shields" filtering a learned policy's actions through a deterministic checker, verification work that states the "learned safeguards can't certify" gap as its opening motivation, and architecture patterns converging on plan-then-execute. Even the field's live disagreement — provable-but-rigid deterministic layers versus flexible-but-uncertifiable learned checks — is, in this frame, not a fight but a placement: you need both, on their proper sides of the irreversibility line, with the learned one permitted only to tighten.
## What to remember
The model proposes; the gate disposes. No is the default, and a refusal must be safe. Only the top of the trust hierarchy widens permissions — a human decision, never the model, a tool result, a summary, or a judge. "Didn't confirm" is not "didn't happen." The desk is finite and the proof doesn't compress, so you measure — and you say *measurement* when you mean measurement. A robot that never stops leaks safety slowly, so it needs scheduled resets — and when it can't reach you, it must be able to stop. A loop that runs robots for you is just a bigger robot with the same rules and a further-away owner. And all of it is a hypothesis wearing its own kill-conditions on its sleeve.
The formal version — the objects, the certificates, the falsifiers, the citations — is [HYPOTHESIS.md](HYPOTHESIS.md). It wins every disagreement with this file, including this sentence.
*Same ramblings, fewer symbols.*
+19 -3
View File
@@ -5,6 +5,7 @@
[![Python](https://img.shields.io/pypi/pyversions/turnstone)](https://pypi.org/project/turnstone/)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/Nh3bWMacaq)
[![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/eous)
Self-hosted, local-first orchestration for tool-using AI agents. Give LLMs real tools — shell, files, search, web — and run them across your own cluster with direct HTTP routing and interactive interfaces. Your code, your models, your data stay on hardware you control: no telemetry, no phone-home.
@@ -16,11 +17,17 @@ Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone)
**What is a harness?**
<p align="center">
<a href="https://media.githubusercontent.com/media/turnstonelabs/turnstone/main/docs/diagrams/harness.png">
<img src="https://media.githubusercontent.com/media/turnstonelabs/turnstone/main/docs/diagrams/harness.png" alt=" : s_{n+1} ~ T(s_n) for n < τ_H — the whole controlled loop: π lowers state to context, M_W proposes a readout, γ authorizes it, Q_E acts on the world, ρ verifies and folds back" width="960"/>
</a>
</p>
```
: s_{n+1} ~ T(s_n) for n < τ*, T = ρ ∘ (M_W ∘ π, E)
: s_{n+1} ~ T(s_n) for n < τ_H
```
[**the hypothesis →**](HYPOTHESIS.md)
[**the primer →**](PRIMER.md) · [**the formalism →**](HYPOTHESIS.md)
### Release Tracks
@@ -124,7 +131,8 @@ Built-in tools for shell, files, search, web, memory, notifications, and autonom
| `turnstone-console` | Cluster dashboard + routing proxy + admin panel |
| `turnstone-channel` | Channel gateway (Discord and Slack adapters) |
| `turnstone-admin` | User/token management CLI |
| `turnstone-eval` | Eval harness for prompt/tool optimization |
| `turnstone-eval` | Headless measurement — scores tool-use against expected actions |
| `turnstone-optimizer` | Prompt/tool optimizer (UCB self-modify loop over the eval substrate) |
| `turnstone-doctor` | LLM-backed cluster diagnostics |
### Diagrams
@@ -170,6 +178,14 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
- Optional: Discord / Slack channel integrations (`pip install turnstone[discord,slack]`)
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
## Support
Turnstone is free, Apache-2.0, and self-hosted — no paid tier, no telemetry, no upsell. If it saves you time or you'd like to help keep development moving, you can sponsor the project:
**[❤ Sponsor Turnstone →](https://github.com/sponsors/eous)** · one-off via **[PayPal](https://paypal.me/eousphoros)**
Sponsorship is entirely optional and funds maintenance, new features, and infrastructure. Prefer to contribute in other ways? Filing issues, improving docs, and [pull requests](CONTRIBUTING.md) help just as much.
## Community
Questions, ideas, or want to show what you're building? Join us on Discord:
+2 -2
View File
@@ -2,11 +2,11 @@ apiVersion: v2
name: turnstone
description: Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation
type: application
version: 0.1.0
version: 0.2.0
appVersion: "0.3.0"
dependencies:
- name: postgresql
version: ~18.7.0
version: ~18.8.0
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
@@ -110,6 +110,153 @@ Determine the PostgreSQL username.
{{- end }}
{{- end }}
{{/*
The PostgreSQL password when the chart stores it itself, empty when it
does not. Doubles as the predicate for "does <fullname>-secrets need to
carry POSTGRES_PASSWORD", so an inline password is never written
anywhere but <fullname>-secrets, and an operator-supplied Secret is
never duplicated into it.
An operator-supplied existingSecret wins outright: writing the value
into a second Secret nothing reads would only duplicate a credential.
Both branches need "default" because this is reached through include,
which captures rendered text rather than a value: a key that is unset
rather than empty — "password:" with nothing after it — renders as the
literal "<no value>", and a ten-character string is truthy. Without the
default that lands base64-encoded in POSTGRES_PASSWORD and the workloads
authenticate with it.
*/}}
{{- define "turnstone.db.inlinePassword" -}}
{{- if .Values.postgresql.enabled }}
{{- .Values.postgresql.auth.password | default "" }}
{{- else if not .Values.database.external.existingSecret }}
{{- .Values.database.external.password | default "" }}
{{- end }}
{{- end }}
{{/*
The name of the bundled subchart's own Secret.
Mirrors the subchart's naming rather than calling its helpers, which
expect a context scoped to the subchart that this chart cannot hand
them. Release-derived, so deliberately not turnstone.fullname: a
fullnameOverride here renames this chart's resources and leaves the
subchart's alone, and pointing at "<fullname>-postgresql" would then
name a Secret that does not exist.
The subchart also normalises the release name through a regex before
using it, which is a no-op for the DNS-1123 names Helm accepts, so it is
not reproduced.
*/}}
{{- define "turnstone.postgresql.fullname" -}}
{{- $global := ((.Values.global).postgresql).fullnameOverride }}
{{- if $global }}
{{- $global | trunc 63 | trimSuffix "-" }}
{{- else if .Values.postgresql.fullnameOverride }}
{{- .Values.postgresql.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := .Values.postgresql.nameOverride | default "postgresql" }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{- define "turnstone.postgresql.secretName" -}}
{{- $existing := coalesce (((.Values.global).postgresql).auth).existingSecret .Values.postgresql.auth.existingSecret }}
{{- if $existing }}
{{- tpl $existing . }}
{{- else }}
{{- include "turnstone.postgresql.fullname" . }}
{{- end }}
{{- end }}
{{/*
The subchart stores the named user's password under "password" and the
superuser's under "postgres-password", and lets an operator rename
either through auth.secretKeys.
*/}}
{{- define "turnstone.postgresql.passwordKey" -}}
{{- $user := .Values.postgresql.auth.username | default "" }}
{{- $keys := .Values.postgresql.auth.secretKeys | default dict }}
{{- if or (empty $user) (eq $user "postgres") }}
{{- $keys.adminPasswordKey | default "postgres-password" }}
{{- else }}
{{- $keys.userPasswordKey | default "password" }}
{{- end }}
{{- end }}
{{/*
Determine the secret holding the PostgreSQL password, and the key within
it. Three sources, and the two helpers agree by construction because
they branch identically:
- an external database pointed at a Secret the chart does not own (a
CloudNativePG-generated secret, an External Secrets target, ...), in
which case the key is rarely "POSTGRES_PASSWORD" — hence the
companion existingSecretPasswordKey
- the bundled subchart's own Secret, when it generates the password
- <fullname>-secrets, when the password is supplied inline in values
Note the last is deliberately not turnstone.llm.secretName: that
resolves to llm.existingSecret when the operator supplies one, which
holds LLM API keys and has no reason to carry a database password.
*/}}
{{- define "turnstone.db.secretName" -}}
{{- if not .Values.postgresql.enabled }}
{{- if .Values.database.external.existingSecret }}
{{- .Values.database.external.existingSecret }}
{{- else }}
{{- printf "%s-secrets" (include "turnstone.fullname" .) }}
{{- end }}
{{- else if include "turnstone.db.inlinePassword" . }}
{{- printf "%s-secrets" (include "turnstone.fullname" .) }}
{{- else }}
{{- include "turnstone.postgresql.secretName" . }}
{{- end }}
{{- end }}
{{- define "turnstone.db.passwordKey" -}}
{{- if not .Values.postgresql.enabled }}
{{- if .Values.database.external.existingSecret }}
{{- .Values.database.external.existingSecretPasswordKey | default "password" }}
{{- else }}
{{- printf "POSTGRES_PASSWORD" }}
{{- end }}
{{- else if include "turnstone.db.inlinePassword" . }}
{{- printf "POSTGRES_PASSWORD" }}
{{- else }}
{{- include "turnstone.postgresql.passwordKey" . }}
{{- end }}
{{- end }}
{{/*
Database environment shared by the server, console and migrate Job.
Every value except the password is rendered inline rather than pulled
from the ConfigMap via envFrom, so that one definition serves all three
workloads and the URL is assembled in exactly one place.
POSTGRES_PASSWORD must still precede TURNSTONE_DB_URL: the kubelet
expands $(VAR) only against env entries declared earlier in the list, so
a later definition would leave a literal "$(POSTGRES_PASSWORD)" in the
URL.
*/}}
{{- define "turnstone.db.env" -}}
- name: TURNSTONE_DB_BACKEND
value: {{ .Values.database.backend | quote }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "turnstone.db.secretName" . }}
key: {{ include "turnstone.db.passwordKey" . }}
- name: TURNSTONE_DB_URL
value: "postgresql+psycopg://{{ include "turnstone.postgresql.username" . }}:$(POSTGRES_PASSWORD)@{{ include "turnstone.postgresql.host" . }}:{{ include "turnstone.postgresql.port" . }}/{{ include "turnstone.postgresql.database" . }}{{ if and (not .Values.postgresql.enabled) .Values.database.external.sslmode }}?sslmode={{ .Values.database.external.sslmode }}{{ end }}"
{{- end }}
{{/*
Determine the secret name for LLM API keys.
*/}}
@@ -7,6 +7,17 @@ metadata:
app.kubernetes.io/component: console
spec:
replicas: {{ .Values.console.replicas }}
{{- if eq (int .Values.console.replicas) 1 }}
# The console registers itself under the fixed service_id "console" and
# deregisters on shutdown. Under RollingUpdate the outgoing pod's
# deregister runs *after* the incoming pod registers and deletes its
# row -- and the heartbeat only touches last_heartbeat, so the row is
# never recreated and the console stays invisible in the registry until
# the next clean start. Recreate orders shutdown strictly before
# startup. Only valid at one replica; see console.replicas.
strategy:
type: Recreate
{{- end }}
selector:
matchLabels:
{{- include "turnstone.selectorLabels" . | nindent 6 }}
@@ -18,6 +29,18 @@ spec:
app.kubernetes.io/component: console
spec:
serviceAccountName: {{ include "turnstone.serviceAccountName" . }}
{{- with .Values.console.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.console.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.console.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: console
image: {{ include "turnstone.image" . }}
@@ -36,8 +59,18 @@ spec:
- secretRef:
name: {{ include "turnstone.llm.secretName" . }}
optional: true
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
env:
{{- include "turnstone.db.env" . | nindent 12 }}
# Self-registration URL for the service registry. Unlike a
# server node the console is one logical endpoint behind its
# Service, so the Service DNS name is correct here. Without
# it the console registers gethostname() (its pod name),
# which no server node can resolve. Stops at ".svc" rather
# than assuming a "cluster.local" DNS domain, which is
# configurable per cluster.
- name: TURNSTONE_CONSOLE_URL
value: "http://{{ include "turnstone.fullname" . }}-console.{{ .Release.Namespace }}.svc:{{ .Values.console.service.port }}"
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
- name: TURNSTONE_JWT_SECRET
valueFrom:
secretKeyRef:
@@ -18,6 +18,18 @@ spec:
app.kubernetes.io/component: server
spec:
serviceAccountName: {{ include "turnstone.serviceAccountName" . }}
{{- with .Values.server.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: server
image: {{ include "turnstone.image" . }}
@@ -39,8 +51,20 @@ spec:
name: {{ include "turnstone.llm.secretName" . }}
optional: true
env:
- name: TURNSTONE_DB_URL
value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)"
{{- include "turnstone.db.env" . | nindent 12 }}
# Each replica is a distinct node in the rendezvous ring, so it
# must advertise an address that reaches *itself*. The Service
# DNS name would load-balance across every replica, sending
# console traffic routed for node A to an arbitrary pod; the
# default (gethostname(), i.e. the pod name) is not resolvable
# at all. The pod IP is unique, routable in-cluster, and
# re-registered on every start, so churn is self-healing.
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: TURNSTONE_ADVERTISE_URL
value: "http://$(POD_IP):{{ .Values.server.service.port }}"
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
- name: TURNSTONE_JWT_SECRET
valueFrom:
@@ -6,11 +6,23 @@ metadata:
{{- include "turnstone.labels" . | nindent 4 }}
app.kubernetes.io/component: migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
# post-install, not pre-install: on a first install nothing the
# migration needs exists yet — not the ConfigMap, not the Secret, and
# with the bundled subchart not the database either, since Helm
# creates ordinary resources only once hooks have finished. On an
# upgrade all of it is already running, so pre-upgrade is both safe
# and preferable: migrations land before the new code rolls out
# rather than after.
"helm.sh/hook": post-install,pre-upgrade
"helm.sh/hook-weight": "-1"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
# Helm does not wait for the database to be ready before running
# post-install hooks, so on a first install this Job is what waits: it
# exits non-zero until PostgreSQL accepts connections, and the retry
# budget has to cover a cold StatefulSet pulling its image and
# initialising.
backoffLimit: 10
template:
metadata:
labels:
@@ -19,6 +31,18 @@ spec:
spec:
serviceAccountName: {{ include "turnstone.serviceAccountName" . }}
restartPolicy: OnFailure
{{- with .Values.migrate.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.migrate.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.migrate.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: migrate
image: {{ include "turnstone.image" . }}
@@ -27,12 +51,5 @@ spec:
- python
- -m
- turnstone.core.storage._migrate
envFrom:
- configMapRef:
name: {{ include "turnstone.fullname" . }}-config
- secretRef:
name: {{ include "turnstone.llm.secretName" . }}
optional: true
env:
- name: TURNSTONE_DB_URL
value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)"
{{- include "turnstone.db.env" . | nindent 12 }}
+20 -7
View File
@@ -1,4 +1,19 @@
{{- if not .Values.llm.existingSecret }}
{{/*
This Secret backs every credential supplied inline in values, so it is
rendered whenever any one of them is set — not, as it once was, only
when llm.existingSecret is empty. Under that older gate an operator who
supplied an LLM Secret lost the unrelated inline values with it: both
POSTGRES_PASSWORD and TURNSTONE_JWT_SECRET silently went unrendered
while the workloads went on referencing them, so every pod stalled in
CreateContainerConfigError.
Each key keeps its own condition, so an operator-supplied Secret still
suppresses the value it replaces and nothing else.
*/}}
{{- $apiKey := and .Values.llm.apiKey (not .Values.llm.existingSecret) }}
{{- $dbPassword := include "turnstone.db.inlinePassword" . }}
{{- $jwtSecret := and .Values.auth.jwtSecret (not .Values.auth.existingSecret) }}
{{- if or $apiKey $dbPassword $jwtSecret }}
apiVersion: v1
kind: Secret
metadata:
@@ -7,15 +22,13 @@ metadata:
{{- include "turnstone.labels" . | nindent 4 }}
type: Opaque
data:
{{- if .Values.llm.apiKey }}
{{- if $apiKey }}
OPENAI_API_KEY: {{ .Values.llm.apiKey | b64enc | quote }}
{{- end }}
{{- if and .Values.postgresql.enabled .Values.postgresql.auth.password }}
POSTGRES_PASSWORD: {{ .Values.postgresql.auth.password | b64enc | quote }}
{{- else if and (not .Values.postgresql.enabled) .Values.database.external.password }}
POSTGRES_PASSWORD: {{ .Values.database.external.password | b64enc | quote }}
{{- if $dbPassword }}
POSTGRES_PASSWORD: {{ $dbPassword | b64enc | quote }}
{{- end }}
{{- if and .Values.auth.jwtSecret (not .Values.auth.existingSecret) }}
{{- if $jwtSecret }}
TURNSTONE_JWT_SECRET: {{ .Values.auth.jwtSecret | b64enc | quote }}
{{- end }}
{{- end }}
+21
View File
@@ -14,7 +14,13 @@ database:
port: 5432
database: turnstone
username: turnstone
# Secret holding the password for `username`. Leave empty to supply
# `password` inline below instead.
existingSecret: ""
# Key within existingSecret holding the password. CloudNativePG
# generates "password"; other operators differ.
existingSecretPasswordKey: password
password: ""
sslmode: prefer
# -- Bitnami PostgreSQL subchart
@@ -37,6 +43,10 @@ server:
service:
type: ClusterIP
port: 8080
# -- Node scheduling constraints
nodeSelector: {}
affinity: {}
tolerations: []
# -- Turnstone console (cluster dashboard)
console:
@@ -51,6 +61,17 @@ console:
service:
type: ClusterIP
port: 8090
# -- Node scheduling constraints
nodeSelector: {}
affinity: {}
tolerations: []
# -- Database migration Job (post-install/pre-upgrade hook)
migrate:
# -- Node scheduling constraints
nodeSelector: {}
affinity: {}
tolerations: []
# -- LLM provider configuration
llm:
+171 -29
View File
@@ -458,7 +458,7 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
| `context_window` | int | Total context window size in tokens |
| `pct` | float | Percentage of context window used |
| `effort` | string | Reasoning effort level (`low`/`medium`/`high`) |
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic) |
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic + OpenAI) |
| `cache_read_tokens` | int | Tokens served from prompt cache (Anthropic + OpenAI) |
**`info`** -- an informational message (e.g. command output).
@@ -467,6 +467,46 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
{"type": "info", "message": "Session cleared."}
```
**`compaction`** -- context-compaction lifecycle (manual `/compact` and
auto-compaction). `phase: "start"` opens the operation (`trigger` is
`"manual"` or `"auto"`; auto adds `where` — e.g. `"mid-turn"` — and, when
the percentage threshold actually fired, `pct`; the context-overflow retry
path compacts without a `pct` since no threshold was evaluated).
`phase: "progress"` reports chunked summarization (`part`/`total`/`depth`,
where depth 0 summarizes transcript batches and deeper levels merge partial
summaries), a transient-error retry wait (`retry_in` seconds + `error`), or
`warning: "summary_truncated"`. `phase: "end"` settles it: `ok: true`
carries `before_tokens`/`after_tokens` and the produced `summary`;
`ok: false` carries a `reason`
(`"not_enough_messages"` / `"irreducible"` / `"empty_summary"` /
`"cancelled"` / `"error"`) and a human-readable `message` — for
`reason: "error"` the same message is also emitted as a paired typed
`error` event (that is the renderable error surface; the end event is
card-teardown). Failed ends also carry `notice`: the emitter-computed
display verdict — show `message` only when it is `true` (the server
suppresses error-reason, superseded, and cancelled-auto notices once,
centrally, so clients don't re-derive that policy). Every end (ok or
failed) carries `trigger`, and every event carries `compaction_id` — an
opaque integer correlating the start/progress/end of one compaction run (a
client that force-stopped one compaction can use it to ignore stragglers
from the abandoned run). End events also carry `superseded`: `true` marks
a force-abandoned compaction retiring after a successor generation took
over (an OK end's result card still stands: the history swap happened).
Superseded start/progress events are never emitted.
Exactly one `start` and one `end` are emitted per attempt,
so clients can key an in-progress affordance (progress bar) on the pair. A
successful end is also persisted: the summary replays from `/history` as a
`role: "system"`, `source: "compaction"` entry whose `meta` carries
`{watermark, before_tokens, after_tokens, trigger}` and whose `event_id`
matches the end event's id (dedup across repaint + replay).
```json
{"type": "compaction", "phase": "start", "compaction_id": 7, "trigger": "auto", "where": "mid-turn", "pct": 80}
{"type": "compaction", "phase": "progress", "compaction_id": 7, "part": 2, "total": 5, "depth": 0}
{"type": "compaction", "phase": "end", "ok": true, "compaction_id": 7, "trigger": "auto",
"before_tokens": 128400, "after_tokens": 9200, "summary": "## Decisions\n..."}
```
**`error`** -- an error message.
```json
@@ -698,6 +738,42 @@ Each skill summary:
---
### `GET /v1/api/personas`
Returns the enabled personas offered by the workstream-creation pickers.
Authenticated for any logged-in user and deliberately gated by **no**
`persona.*` permission — selecting a persona at creation is a user
action, while the `persona.*` perms gate authoring. Display fields only;
the levers (base prompt, tool set, MCP/memory toggles) stay server-side.
**Response:**
```json
{
"personas": [
{"name": "engineer", "display_name": "Engineer", "description": "The stock interactive workstream: full tools, MCP, and memory.", "applies_to_kinds": ["interactive"], "is_default": true},
{"name": "researcher", "display_name": "Researcher", "description": "Answers questions with evidence — reads and cites, loads tools to verify when needed.", "applies_to_kinds": ["interactive"], "is_default": false}
],
"total": 2
}
```
Each persona summary:
| Field | Type | Description |
|--------------------|--------|------------------------------------------------------------------|
| `name` | string | Persona slug (used in the `persona` field on workstream creation) |
| `display_name` | string | Human-readable label for pickers |
| `description` | string | Short description of the persona's intent |
| `applies_to_kinds` | array | Workstream kinds the persona applies to (`interactive` / `coordinator`) |
| `is_default` | bool | Whether this is the default persona for its kind |
> **Note:** For full persona management (create, edit, archive), use the
> admin endpoints at `/v1/api/admin/personas` (requires the
> `persona.{create,read,write}` permissions).
---
### `POST /v1/api/workstreams/{ws_id}/send`
Sends a user message to a workstream. Spawns a daemon worker thread that calls
@@ -712,32 +788,41 @@ Sends a user message to a workstream. Spawns a daemon worker thread that calls
**Request body:**
```json
{"message": "Explain how the server works"}
{"message": "Explain how the server works", "attachment_ids": ["a1"]}
```
| Field | Type | Required | Description |
|-----------|--------|----------|-------------------------|
| `message` | string | yes | The user's message text |
| Field | Type | Required | Description |
|------------------|------------|----------|------------------------------------------------------|
| `message` | string | yes | The user's message text |
| `attachment_ids` | string[] | no | Staged uploads to attach (omit = auto-consume; `[]` = none) |
**Response (success):**
**Response.** Every 200 body carries `attached_ids` and
`dropped_attachment_ids` (empty lists when no attachments are involved):
```json
{"status": "ok"}
```
**Response (busy):** Returned if the workstream's worker thread is still alive
from a previous request. Also pushes a `busy_error` event to the SSE stream.
```json
{"status": "busy"}
```
- `{"status": "ok", ...}` — a fresh turn was dispatched.
- `{"status": "queued", "priority", "msg_id", ...}` — folded into the live
turn's interjection queue; delivered at the next tool-result seam.
`DELETE .../send` with the `msg_id` retracts it before delivery.
- `{"status": "queued", "deferred": true, ...}` — parked on the deferred-send
list (a command window holds the slot, or earlier deferred sends are
pending) and dispatched as its own full-fidelity send afterwards; see the
defer contract under `POST /v1/api/command`.
- `{"status": "queue_full", ...}` — the send was refused with retry-shortly
semantics: the live worker's interjection queue is at capacity, the
deferred-send list hit its saturation bound (10 pending — the same
backpressure contract), or the deferred-send drain could not be started
under resource exhaustion (the message was **not** accepted; nothing is
parked).
- `{"status": "attachments_busy", ...}` — attachments can't ride a queued
turn; the staged uploads survive for a retry once the worker idles.
**Error responses:**
| Status | Body | Condition |
|--------|------------------------------------|------------------------|
| 400 | `{"error": "Empty message"}` | Message is empty |
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found |
| Status | Body | Condition |
|--------|-------------------------------------------------|----------------------------------------|
| 400 | `{"error": "message is required"}` | Message is empty |
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found (or closed mid-send) |
| 409 | `{"status": "cross_user_interjection", ...}` | Another participant's turn is in flight |
---
@@ -780,7 +865,56 @@ automatically approved without prompting.
### `POST /v1/api/command`
Executes a slash command in the given workstream.
Executes a slash command in the given workstream. Commands run on the
workstream's worker slot (mutual exclusion against sends, a running
compaction, and each other) — the endpoint is **not** unconditionally
synchronous:
- **Quick commands** (everything except `/compact`): the endpoint waits for
completion, so `{"status": "ok"}` means the command ran. A command still
running after 25 s answers `{"status": "running"}` — the worker keeps
going, its output reaches the pane via SSE, and the post-command pane
refreshes below still fire when it completes. (The bound sits under
common 30 s client/proxy timeouts — the console proxy's included — so
the degraded answer actually reaches bounded callers.)
- **`/compact`**: dispatched fire-and-forget — `{"status": "ok"}` means the
compaction *started*. A large context can legitimately compact for many
minutes; progress streams as `compaction` SSE events (see the event
reference) and the persisted marker row lands on completion. Do not read
`/history` expecting the compacted transcript immediately after the
response.
- **Busy refusal**: if a turn or another command holds the worker slot, the
command is refused with HTTP **409** `{"status": "busy", "error": ...}` and
did **not** run. Retry after the current turn finishes. (The old inline
endpoint executed commands unconditionally mid-turn; the 409 makes the
refusal loud for callers that only check the HTTP status.)
While a command holds the slot — and afterwards, while earlier deferred
sends are still waiting (the pending list is the order authority: a fresh
send never overtakes a message already acknowledged) — `POST .../send`
requests are **deferred**: the server answers `{"status": "queued",
"deferred": true, "msg_id": ...}` immediately and dispatches the message
as an ordinary full-fidelity send (attachments and sender identity
included) in arrival order once the slot frees — it is never routed
through the mid-turn interjection queue (no length cap, no cross-user
rejection). The response arrives within normal round-trip time, so
timeout-bounded clients (SDKs, proxies, the coordinator) need no special
handling. To retract a deferred send before it dispatches, issue the same
`DELETE .../send` with its `msg_id` used for queued interjections —
`{"status": "removed"}` confirms it will not dispatch; `"not_found"` means
it already dispatched (or is dispatching). Retracting a deferred send
discards any attachments it carried; re-attach to send them again. When a
deferred send dispatches, panes receive a `message_dispatched` event
(`msg_id`, plus `folded: true` when it folded into a live turn's
interjection queue rather than spawning its own turn) so queued-message
UI can settle the right way.
Durability: deferred sends are **node-local and in-memory** (the same
lifetime as the interjection queue). `"queued"` is at-most-once intake, not
durable acceptance — if the workstream is closed or the node restarts before
the window ends, the message is dropped. Anything that must survive a
restart should be re-sent after confirming dispatch (the turn appears on the
SSE stream / in `/history`).
**Request body:**
@@ -793,10 +927,12 @@ Executes a slash command in the given workstream.
| `command` | string | yes | The slash command (e.g. `/clear`) |
| `ws_id` | string | yes | Target workstream ID |
If the command is `/clear` or `/new`, the server pushes a `clear_ui` SSE event
to instruct the client to reset its message display. If the command is
`/resume`, the server pushes `clear_ui` followed by a `history` event
containing the resumed session's messages.
If the command is `/clear`, `/new`, or `/resume`, the server pushes a
`clear_ui` SSE event to instruct the client to reset its message display and
re-fetch the transcript via `GET .../history` (there is no SSE event that
carries the messages themselves). These follow-ups are emitted by the
command worker itself, so they fire even when the endpoint already answered
`{"status": "running"}`.
**Response:**
@@ -804,12 +940,16 @@ containing the resumed session's messages.
{"status": "ok"}
```
or `{"status": "running"}` as above.
**Error responses:**
| Status | Body | Condition |
|--------|------------------------------------|----------------------|
| 400 | `{"error": "Empty command"}` | Command is empty |
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found |
| Status | Body | Condition |
|--------|-------------------------------------|--------------------------------------------------|
| 400 | `{"error": "Empty command"}` | Command is empty |
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found |
| 409 | `{"status": "busy", "error": ...}` | A turn/command holds the worker |
| 503 | `{"status": "error", "error": ...}` | The command worker could not be started (resource exhaustion) — the command did **not** run; retry shortly |
---
@@ -895,6 +1035,7 @@ All fields are optional. The body can be empty or an empty JSON object.
| `auto_approve` | bool | false | Auto-approve all tool calls for this workstream |
| `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)|
| `skill` | string | "" | Skill name. Applies content (system prompt), model, temperature, reasoning effort, max tokens, auto-approve policy, token budget, and other session config from the skill. Returns 400 if not found or disabled. Ignored when `resume_ws` is set (resumed sessions restore their own skill). |
| `persona` | string | "" | Persona slug. Resolved and snapshotted into the workstream at creation; empty selects the kind's default. |
| `judge_model` | string | "" | Optional model alias for the judge (overrides default judge model for this workstream) |
> **Skill behavior:** When `skill` is specified, the skill's content is injected as a system message and its session config fields (model, temperature, auto-approve, token budget, etc.) override system defaults for the new workstream.
@@ -911,6 +1052,7 @@ All fields are optional. The body can be empty or an empty JSON object.
| `name` | string | Auto-generated workstream name |
| `resumed` | bool | Whether a previous session was successfully resumed |
| `message_count` | int | Number of messages in the resumed session (0 if fresh) |
| `initial_message_status` | string | Present ONLY when the workstream was created but its `initial_message` could not be delivered: `"queue_full"` (a raced live worker's interjection queue was at capacity — resend via `/send`; any uploads stay staged) or `"refused_closed"` (the workstream was closed mid-create). Absent whenever the message was dispatched. |
**Error (limit reached):**
+221 -70
View File
@@ -19,7 +19,8 @@ plugs in.
| `turnstone` | `turnstone.cli` | `TerminalUI` | Interactive terminal REPL |
| `turnstone-server` | `turnstone.server` | `WebUI` | Browser-based chat (HTTP + SSE) |
| `turnstone-console` | `turnstone.console.server` | ClusterCollector | Cluster dashboard (aggregates all nodes) |
| `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization |
| `turnstone-eval` | `turnstone.eval.cli` | `NullUI` | Headless measurement (scores tool-use against expected actions) |
| `turnstone-optimizer` | `turnstone.optimizer` | `NullUI` | Prompt/tool optimization (UCB self-modify loop over the eval substrate) |
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) |
| `turnstone-admin` | `turnstone.admin` | — | Offline user and API token management |
| `turnstone-doctor` | `turnstone.doctor` | — | LLM-backed cluster diagnostics |
@@ -90,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.17.0/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
katex-0.18.1/ 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)
@@ -127,13 +128,16 @@ A user message flows through the system as follows:
_emit_state("thinking")
|
v
_create_stream_with_retry() ----> provider.create_streaming(client, model, messages, ...)
_stream_response() -------------> model_turn(lane, turns, on_chunk=...) per attempt
| lane-swap fallback walk; per-lane ladder:
| up to 3 retries (4 total attempts), exponential backoff
v
_stream_response(stream) --------> dispatch tokens to UI:
the on_chunk consumer -----------> display grid ONLY:
| on_reasoning_token() / on_content_token()
| accumulate tool_calls from deltas
| track finish_reason
| tool-call deltas just flush the splitter
| (assembly lives in drain_stream, inside
| model_turn — the consumer never accumulates)
| track finish_reason (citations-footer gate)
| _check_cancelled() per chunk (cooperative cancel)
v
finish_reason check:
@@ -242,7 +246,9 @@ class SessionUI(Protocol):
def on_content_token(self, text: str) -> None: ...
def on_stream_end(self) -> None: ...
def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ...
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
def on_tool_result(
self, call_id: str, name: str, output: str, *, is_error: bool = False
) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
def on_info(self, message: str) -> None: ...
@@ -267,7 +273,7 @@ the per-workstream events stream in
|-------|--------|-------|
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
| `NullUI` | `turnstone.eval.core` | Discards all output; `approve_tools` always returns `(True, None)` |
### WorkstreamTerminalUI
@@ -312,15 +318,15 @@ ERROR last operation failed
```python
@dataclass
class Workstream:
id: str # uuid hex, 8 chars
name: str # user-visible label
state: WorkstreamState # current state
session: ChatSession | None # the conversation engine
ui: SessionUI | None # frontend adapter
id: str # uuid hex, 8 chars
name: str # user-visible label
state: WorkstreamState # current state
session: ChatSession | None # the conversation engine
ui: SessionUI | None # frontend adapter
worker_thread: threading.Thread | None
error_message: str
last_active: float # time.monotonic() timestamp, updated on every state change
_lock: threading.Lock # per-workstream state lock
last_active: float # time.monotonic() timestamp, updated on every state change
_lock: threading.Lock # per-workstream state lock
```
### WorkstreamManager
@@ -332,7 +338,9 @@ class WorkstreamManager:
def __init__(self, session_factory: Callable[[SessionUI], ChatSession]): ...
def create(self, name="", ui_factory=None) -> Workstream: ...
def close(self, ws_id: str) -> bool: ...
def close_idle(self, max_age_seconds: float) -> list[str]: ... # auto-close stale IDLE workstreams
def close_idle(
self, max_age_seconds: float
) -> list[str]: ... # auto-close stale IDLE workstreams
def get(self, ws_id: str) -> Workstream | None: ...
def get_active(self) -> Workstream | None: ...
def list_all(self) -> list[Workstream]: ...
@@ -507,7 +515,7 @@ then returns the final content as the tool result.
response without tools. When unlimited, the loop only exits when the model
stops calling tools or hits `finish_reason: "length"`.
- **Retry**: each API call in the agent loop uses the same retry+backoff logic
as the main `_create_stream_with_retry()`.
as the main loop's per-lane ladder (`_model_turn_with_retry`).
- **Finish reason handling**: `finish_reason: "length"` stops the agent early
and returns whatever content was generated. `finish_reason: "content_filter"`
returns a placeholder.
@@ -608,8 +616,7 @@ LLMProvider (protocol)
| Method | Purpose |
|--------|---------|
| `create_streaming()` | Streaming request, yields normalized `StreamChunk` objects |
| `create_completion()` | Non-streaming request, returns `CompletionResult` |
| `create_streaming()` | The one transport: streaming request, yields normalized `StreamChunk` objects (single-shot callers accumulate via `drain_stream()` into a `CompletionResult`) |
| `get_capabilities()` | Per-model flags (`ModelCapabilities`) |
| `convert_tools()` | Translate OpenAI tool schemas to provider format |
| `retryable_error_names` | Exception class names that trigger retry |
@@ -621,20 +628,30 @@ LLMProvider (protocol)
|------|--------|
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason`, `provider_blocks` |
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage`, `provider_blocks` |
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay` |
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay`, `supports_verbosity`, `verbosity`, `supports_pro_mode`, `reasoning_mode` |
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens`, `cache_creation_tokens`, `cache_read_tokens` |
**OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are
already in OpenAI format), including multi-part content blocks (text + images)
in tool results. Model capability lookup table covers GPT-5/5.1/5.2/5.3/5.4,
in tool results. Model capability lookup covers GPT-5 through GPT-5.6,
O-series, and search models (`gpt-5-search-api`) — all with `supports_vision`.
For search models, injects `web_search_options` and removes the `web_search`
function tool (the model always searches). Citations from `url_citation`
annotations are formatted as footnotes. Extended prompt cache retention
(`prompt_cache_retention: "24h"`) is enabled for GPT-5.x models at no
additional cost. Cached token counts are extracted from
`usage.prompt_tokens_details.cached_tokens`. Unknown models (local servers) get
permissive defaults with `supports_vision=False` and use SearxNG for web search.
annotations are formatted as footnotes. Pre-5.6 GPT-5 models request extended
prompt-cache retention (`prompt_cache_retention: "24h"`); GPT-5.6 uses
`prompt_cache_options.ttl: "30m"`. Cache reads and writes are extracted from
`cached_tokens` and `cache_write_tokens`. Unknown models get permissive
defaults with `supports_vision=False` and use SearxNG for web search. The
`openai-compatible` lane never consults this table at all — on either API
surface (the responses pin is served by a compat-mode
`OpenAIResponsesProvider`, mirroring `AnthropicProvider(compat=True)`): a
local server serves whatever the operator named it (vLLM
`--served-model-name` is a free string), so a prefix collision with a cloud
model id must not inherit that model's sampling/effort contract — every
local model gets the plain defaults, commercial prompt-cache controls are not
injected by model-name prefix, and anything beyond those defaults is declared
on the model definition (capabilities JSON + `server_compat`), matching the
`anthropic-compatible` lane.
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
Anthropic content blocks, maps `system`/`developer` roles to the `system`
@@ -652,7 +669,7 @@ display). Automatic prompt caching is enabled via top-level `cache_control:
cacheable block and advances it as conversations grow (90% input cost
reduction on cache hits, 1.25x write on first turn). Cache metrics
(`cache_creation_input_tokens`, `cache_read_input_tokens`) are extracted from
both streaming and non-streaming responses. The `anthropic` SDK is a core
the stream's usage events. The `anthropic` SDK is a core
dependency — the Anthropic provider is first-class alongside OpenAI.
**GoogleProvider** (`_google.py`): extends `OpenAIChatCompletionsProvider` for
@@ -797,15 +814,105 @@ model = "deepseek-ai/DeepSeek-V4-Flash"
supports_vision = true # multimodal checkpoints only
supports_mid_conversation_system = true # template-dependent
context_window = 131072
thinking_mode = "manual" # session effort knob drives the template toggle
thinking_param = "enable_thinking" # Qwen/Gemma key; "thinking" for Granite/DeepSeek
```
The reasoning toggle does NOT use Anthropic's `thinking` request param.
Toggle it through the chat template instead: set `{"chat_template_kwargs":
{"thinking": false}}` as extra body params in the admin Models
server-compat section (for this provider the section shows only the
extra-body field — server type, API surface, and thinking mode are
openai-compatible-only knobs); the provider forwards it via the SDK's
`extra_body`.
Reasoning control does NOT use Anthropic's `thinking` request param
the levers live in the chat template, reached through
`chat_template_kwargs` in the request body. Two channels, dynamic first:
* **Session effort knob (dynamic).** Set the model's thinking mode to
"Effort-knob controlled" in the admin Models form (or
`thinking_mode = "manual"` + `thinking_param` under
`[models.*.capabilities]`) and the provider maps the session's
reasoning-effort knob onto the template toggle per-request: effort
`none` sends `{<thinking_param>: false}`, any other level sends
`true` — the same contract as the real lane's manual mode. ("Always
on" / `thinking_mode = "adaptive"` instead always sends `true`: the
model self-regulates, so the knob never force-disables — mirroring
the native adaptive branch.) The graded effort value always rides
alongside the toggle: under `effort_param` when the operator names
the template's key, else under the conventional fallback key
(`reasoning_effort`) on the anthropic-compatible lane — the user's
effort setting always reaches the wire, and a template that doesn't
reference the kwarg ignores it. On the openai-compatible lane the
undeclared-key case rides the flat top-level `reasoning_effort`
param instead (the documented compat field), forwarded verbatim.
Optional `reasoning_effort_values` / `default_reasoning_effort`
validate the knob before it reaches the server; without declared
values the knob is forwarded as-is. The knob is ordinal, and validation
respects that: an off-list knob value rounds UP onto the declared
list and a value above the ceiling rides the ceiling
(`snap_reasoning_effort`) — asking for more effort than the model
declares never falls back to a lower default tier. The knob's
`none` position is forwarded verbatim when the model declares an
explicit `none` level (gpt-5.1+, grok-4.3) — omitting it there would
leave a reasoning-on server default (e.g. gpt-5.5's `medium`) in
charge of a knob that promises off — and omitted otherwise; `none`
is never a snap target for other positions.
`default_reasoning_effort` only catches values the ordinal snap
cannot rank (custom strings). Declare values that match the
template's documented vocabulary: for DeepSeek-V4, which officially
accepts `high`/`max` (Think High is the default thinking tier;
`low`/`medium` alias to `high`, `xhigh` to `max`), a
`("high", "max")` values list reproduces the official aliasing
exactly — `low`/`medium` round up to `high`, `xhigh` to `max`
and freeform passthrough matches it too. To map an undocumented
template, probe with per-request `chat_template_kwargs` and compare
`input_tokens`. Setting `effort_param` also suppresses the
flat top-level `reasoning_effort` request param on the
openai-compatible lane — the template channel replaces it, never
doubles it. With the default `thinking_mode = "none"` nothing is
injected and the server's template default decides.
Upgrade note: before 1.7.0a7 the openai-compatible lane sent the
toggle unconditionally `true` whenever thinking mode was enabled. A
stored per-model `reasoning_effort = "none"` now disables thinking
on such models — pick any real level (or clear the override) to keep
it on. Also since 1.7.0a7 the effort level itself always reaches the
wire on the local lanes (previously dropped unless
`reasoning_effort_values` was declared): flat `reasoning_effort` on
openai-compatible, the `effort_param`-or-fallback template key on
anthropic-compatible when reasoning control is engaged.
* **Operator pin (static).** Entries under `{"chat_template_kwargs":
...}` in the admin Models extra-body field ride the SDK's
`extra_body` unconditionally and win over the knob mapping on key
collision — e.g. pin `{"enable_thinking": true}` to keep thinking on
regardless of the session knob. (Server type and API surface remain
openai-compatible-only knobs and stay hidden for this provider.)
The same knob mapping drives the `openai-compatible` lane's Chat
Completions requests — `merge_reasoning_template_kwargs` is shared by
both local-server lanes, so `thinking_mode`/`thinking_param`/
`effort_param` mean the same thing whichever endpoint serves the model.
Only the Responses API surface (native reasoning) ignores it.
The console surfaces this projection as an *effective effort ladder*:
the admin model form's per-model effort select and the skill
launch-config effort select annotate each position with what the
request will carry, in plain words — a position whose delivered level
matches its name stays plain ("Max"), a snapped position says so
("Low — sends high"), the adaptive lanes' none position warns
"thinking stays on", and budget detail lives in the tooltip. A
position is never labeled after a sibling that shares its wire (that
rendered "Max (= minimal)", implying a downgrade the wire doesn't
contain). Computed server-side by `providers/effort_ladder.py` from
the same mapping functions the providers use at request time and
shipped on `/v1/api/models` rows (every row carries `effort_ladder`,
empty when the capabilities column fails to parse) and
`POST /v1/api/admin/models/effort-ladder`. The ladder describes what
Turnstone sends — a server-side template may alias further (DeepSeek-V4
folds `low`/`medium` into its default `high` tier).
The `anthropic-compatible` lane never sends Anthropic's native
`thinking`/`output_config` params — they are not in vLLM's request
schema. The real `anthropic` provider is unaffected: official Claude
models keep native thinking, budget mapping, and `output_config`
effort. A gateway fronting *real* Claude on a Messages-shaped URL
(e.g. a LiteLLM `anthropic/` route to the Claude API) should use
`provider = "anthropic"` with a custom `base_url`, which keeps the
native thinking params.
Verified quirks of vLLM's Anthropic endpoint:
@@ -841,8 +948,8 @@ with the same alias in-memory (the DB rows are never modified).
5. `/model` command shows available models; `/model <alias>` switches the
active workstream's client, model, context window, and per-model sampling
parameters
6. `_create_stream_with_retry()` tries the primary model, then each fallback
alias in order if the primary is unreachable
6. `_model_turn_with_fallback()` tries the primary lane, then each fallback
alias's lane in order if the primary is unreachable
7. `_run_agent()` resolves `registry.agent_model` (if set) for task
sub-agents, allowing a cheaper model for autonomous loops
@@ -867,6 +974,33 @@ The default limit is 50% of the context window in characters (computed as
This truncation message is visible to the model, so it knows output was cut.
During the send loop the limit is additionally capped by the remaining
context budget, and three guarantees apply when that budget reaches zero
(#883):
- **Structural floor** — orchestration handles (`spawn_workstream`,
`spawn_batch`, `wait_for_workstream`, `tasks`) and error results are
always admitted up to a guaranteed floor (2048 chars, head+tail beyond
it), because a lost `ws_id` or a masked failure wedges the session.
- **Small-result pass** — results at or under the floor pass verbatim,
funded from a bounded per-batch grace pool (2× the floor) so a wide
batch of small results cannot collectively bypass budget accounting;
past the pool they get the drop notice instead.
- **Honest drop notice** — a bulky non-structural result is replaced by an
explicit `Error: tool result dropped — context budget exhausted…` notice
stating the call ran but its output could not be admitted (never a
successful-looking trim).
A zero budget also triggers one mid-turn auto-compaction before results are
sized. With `max_tokens ≥ context_window/4` the response reserve zeroes the
budget near 70% fullness — below the default 80% auto-compact threshold —
and without this trigger a session could idle in that band indefinitely
with every tool result floored or dropped. The trigger keys on the
exhausted budget itself, not on any threshold, so it composes with any
operator-set `auto_compact_pct`: with thresholds below the zero point the
ordinary owed-compaction paths fire first and this trigger degrades to a
backstop for the cases where they bailed or freed too little.
---
## Persistence
@@ -1017,9 +1151,10 @@ reconstructs the OpenAI message format from database rows:
in the same workstream
**Config persistence:** LLM-affecting parameters (`temperature`,
`reasoning_effort`, `max_tokens`, `instructions`, `creative_mode`) are
persisted to the `workstream_config` table on creation and whenever changed
via slash commands. `resume()` restores these values so resumed workstreams
`reasoning_effort`, `max_tokens`, `instructions`, and the persona
snapshot — see `docs/personas.md`) are persisted to the
`workstream_config` table on creation and whenever changed via slash
commands. `resume()` restores these values so resumed workstreams
behave identically to the original.
**`/clear` vs `/new`:** `/clear` wipes in-memory context but preserves
@@ -1048,20 +1183,30 @@ Named (aliased) workstreams are never age-pruned. Configure with
### API Retry
`ChatSession._create_stream_with_retry()` (streaming path) and the agent
`_api_call()` (non-streaming) both use the same retry pattern:
Every model call streams (#831); retry lives at two stacked layers:
- **Retries**: 4 total attempts (1 initial + 3 retries, `_MAX_RETRIES = 3`)
- **Backoff**: exponential, base 1 second (`delay = 1s * 2^attempt`)
- **Retryable errors**: `RateLimitError`, `APITimeoutError`,
`APIConnectionError`, `InternalServerError`, `ServiceUnavailableError`,
`APIError` (matched by class name to avoid importing backend-specific
exception hierarchies)
- On retry: `ui.on_info()` notification
- On final failure: exception propagates
`_compact_messages()` also wraps its non-streaming API call in the same
retry loop.
- **Caller ladders**`ChatSession._model_turn_with_retry()` (chat
loop, one ladder per lane) and the agent `_api_call()` (drained via
`model_turn`) use the same pattern: 4 total attempts (1 initial + 3 retries,
`_MAX_RETRIES = 3`), exponential backoff base 1 second
(`delay = 1s * 2^attempt`), `ui.on_info()` on retry, exception
propagates on final failure. `_compact_messages()` wraps its drained
call in the same loop.
- **`model_turn`'s drain ladder** — inside every single-shot call,
mid-stream deaths (errors raised while draining, e.g.
`IncompleteStreamError`) are re-issued up to 2 more times with a
0.5s-base exponential backoff (±50% jitter); request-time failures
keep the SDK's own retry policy. The two ladders stack
multiplicatively on transient-shaped failures.
- **Retryable errors** are matched by class name against each
provider's `retryable_error_names` (avoids importing
backend-specific exception hierarchies): `RateLimitError`,
`APITimeoutError`, `APIConnectionError`, `InternalServerError`,
`ServiceUnavailableError`, `APIError`, plus the drained-transport
errors `IncompleteStreamError` (stream ended with no terminal
signal — for servers that never send one, declare
`finish_reason_optional` in the model's capabilities JSON) and
`ResponsesStreamFailedError` (transient in-band Responses failure).
### Finish Reason Handling
@@ -1074,7 +1219,7 @@ retry loop.
blocked.
Agent sub-sessions (`_run_agent()`) check `finish_reason` on each
non-streaming response and stop the agent early on `"length"` or
drained turn and stop the agent early on `"length"` or
`"content_filter"`.
`_compact_messages()` checks `finish_reason` on the compaction response and
@@ -1118,28 +1263,34 @@ warns if the summary was truncated.
`_run_single_test()`: wraps `session.send_headless()` in a retry loop (3
attempts) to avoid transient API errors from poisoning evaluation scores.
### Health Monitor & Circuit Breaker
### Backend Health Tracking
`BackendHealthMonitor` (`turnstone/core/healthcheck.py`) runs a daemon thread
that probes the LLM backend by calling `client.models.list()` every
`backend_probe_interval` seconds (default 30). Probe results drive a three-state
circuit breaker:
`BackendHealthTracker` (`turnstone/core/healthcheck.py`) records LLM backend
health passively from real request outcomes — there is no probe thread and no
circuit breaker, and requests are never blocked. Two states:
```
CLOSED ──(N consecutive failures)──> OPEN
OPEN ──(cooldown expires)────────> HALF_OPEN
HALF_OPEN ──(probe succeeds)────────> CLOSED
HALF_OPEN ──(probe fails)──────────> OPEN
healthy ──(failure_threshold consecutive failures)──> degraded
degraded ──(any success)───────────────────────────> healthy
```
- `record_success()` / `record_failure()` update `_consecutive_failures` and
transition the `_state` (`CircuitState` enum: `CLOSED`, `OPEN`, `HALF_OPEN`).
- `acquire_request_permit()` returns `False` when the circuit is `OPEN` or when
in `HALF_OPEN` and the single probe permit has already been consumed. Causes
`ChatSession._create_stream_with_retry` to skip the backend and surface an
error immediately.
- The `/health` endpoint reads the monitor's state: `"status": "ok"` when the
circuit is closed, `"status": "degraded"` when open or half-open.
- `record_success()` fires at the request-accepted instant: the streaming
consumer's `on_stream_armed` hook, driven by the eager `cancel_ref` append
every adapter performs at HTTP-response time.
- `record_failure()` fires once per lane's whole creation ladder, in
`ChatSession._model_turn_with_fallback` / `_try_fallback_lane`. A mid-stream
death (the stream armed, then died) records neither — it belongs to the
re-issue ladder, not the fallback walk. `BackendAuthUnavailableError` and
`WirePreparationError` also record nothing: an auth refusal is fail-closed
configuration policy and a wire-preparation fault is session data — neither
says anything about the backend.
- `is_degraded` is advisory ordering, not admission: the fallback walk tries
non-degraded aliases first and degraded ones as a last resort, and the
primary lane is always dialed.
- `HealthTrackerRegistry` keys trackers by `(provider, base_url)` so aliases
sharing a backend share one tracker. The `/health` endpoint projects the
same trackers: `"status": "ok"` when the backend is healthy, `"degraded"`
otherwise.
### Rate Limiting
+4 -3
View File
@@ -379,6 +379,7 @@ Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated
Triggered by the "+ new" header button. A modal dialog with:
- **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" picks a node with available capacity using round-robin, or a specific node from the list (showing capacity).
- **Persona** — optional dropdown listing the enabled personas for the workstream kind. Sets the system-message composition and capability envelope at creation, snapshotted server-side; empty uses the kind's default. Picking one requires no `persona.*` permission.
- **Profile** — optional dropdown listing enabled skills. Applies the skill's model, auto-approve policy, token budget, and other behavioral settings at creation time.
- **Name** — optional text input. Auto-generated if left empty.
- **Model** — optional text input for a model alias from the target node's registry.
@@ -396,9 +397,9 @@ The browser maintains a local `clusterState` object that mirrors the cluster sna
Accessed via the "admin" button in the header (visible when authenticated
with `approve` scope). Provides user, API token, channel link, MCP server,
and skill management with 18 tabs (Users, API Tokens, Channels, Schedules,
Watches, Roles, Policies, Prompts, Judge, Skills, MCP Servers, Usage,
Audit, Memories, Models, Nodes, Settings, TLS). See also
and skill management with tabs that include Users, API Tokens, Channels,
Schedules, Watches, Personas, Roles, Policies, Prompts, Judge, Skills,
MCP Servers, Usage, Audit, Memories, Models, Nodes, Settings, and TLS. See also
[Governance](governance.md) for the Roles, Policies, Skills, Usage, and
Audit tabs, and [Settings](settings.md) for the database-backed
configuration editor.
+1 -1
View File
@@ -366,7 +366,7 @@ deleted.
## Further reading
- [coordinator-skills.md](coordinator-skills.md) — writing a skill
that runs on a coordinator session (orchestrator persona,
that runs on a coordinator session (orchestrator framing,
workflow patterns, `SkillKind` classifier).
- [bulk-endpoints.md](bulk-endpoints.md) — the two bulk-shape
idioms (`{results, denied, truncated}` vs
+32 -22
View File
@@ -1,13 +1,13 @@
# Writing a coordinator-specific skill
Skills are prompt-level personas that steer a Turnstone session
A skill is prompt-level framing that steers a Turnstone session
toward a narrow task. Most skills target **interactive** sessions —
the single-workstream "do this thing" surface where the model wields
`bash`, `edit_file`, `web_fetch`, and the rest of the maker toolset.
A **coordinator skill** is different. It runs on a session whose job
is to orchestrate other sessions. The toolset is smaller and
narrower, the persona is an orchestrator instead of a maker, and the
narrower, the role is an orchestrator instead of a maker, and the
success metric is "did the plan resolve" instead of "did the code
compile". This doc covers the differences a skill author has to
care about.
@@ -22,8 +22,8 @@ migration 044 added the column). Three values:
| `SkillKind` enum | Stored as | Meaning |
|-------------------------|-----------------|----------------------------------------------------------------------------|
| `SkillKind.INTERACTIVE` | `"interactive"` | Authored for the interactive maker persona (single-workstream "do this"). |
| `SkillKind.COORDINATOR` | `"coordinator"` | Authored for the orchestrator persona (delegate, monitor, synthesise). |
| `SkillKind.INTERACTIVE` | `"interactive"` | Authored for the interactive maker role (single-workstream "do this"). |
| `SkillKind.COORDINATOR` | `"coordinator"` | Authored for the orchestrator role (delegate, monitor, synthesise). |
| `SkillKind.ANY` | `"any"` | Either surface (or audience-neutral). Default on create. |
The `kind` field is a `StrEnum` — drop-in `str` compatible — so DB
@@ -96,20 +96,20 @@ for the output. The coordinator stays the orchestrator.
---
## Persona differences
## Framing differences
Interactive skills compose on top of `base_interactive.md` — a
"maker" persona: get the work done, use the tools, edit the code,
"maker" framing: get the work done, use the tools, edit the code,
close the loop.
Coordinator skills compose on top of
[`base_coordinator.md`](../turnstone/prompts/base_coordinator.md) —
an "orchestrator" persona: decompose, delegate, monitor, synthesise.
[`personas/orchestrator.md`](../turnstone/prompts/personas/orchestrator.md) —
an "orchestrator" framing: decompose, delegate, monitor, synthesise.
The base text is short but sets the tone every coordinator skill
inherits:
> You are a coordinator on a small, focused infrastructure team.
> Your role is to orchestrate work across the cluster... You do
> You are a coordinator. Your role is to orchestrate work across
> the cluster... You do
> not edit files, run shell commands, browse the web, or manipulate
> the codebase directly. Children do that.
@@ -126,10 +126,11 @@ the skill should end on.
`tasks` is the coordinator's scratchpad — a persisted, ordered
list of rows with fields `{id, title, status, child_ws_id, created,
updated}` that only this coordinator sees. Children don't see it;
the user does via the sidebar. Five actions: `add`, `update`,
`remove`, `reorder`, `list` (only `list` is auto-approved; the
mutators go through the approval flow).
updated}`, plus `note` on rows where one has been set (the key is
absent otherwise), that only this coordinator sees. Children don't
see it; the user does via the sidebar. Five actions: `add`,
`update`, `remove`, `reorder`, `list` (only `list` is auto-approved;
the mutators go through the approval flow).
The input schema refers to rows by `task_id`; the persisted row
object exposes the same id as `id`. The `child_ws_id` field is a
@@ -142,11 +143,20 @@ A skill's initial prompt can seed the task list by calling
`tasks(action="add", title=...)` as its very first tool calls —
the user gets a visible plan before any child is spawned, and the
coordinator's future self has something concrete to iterate on.
Status transitions (`pending``in_progress``done` / `blocked`)
are the skill's main feedback loop: mutate the task when the child
covering it finishes, not when the child starts. Use
`tasks(action="update", task_id=..., child_ws_id=<ws_id>)` to
link a task to the child that owns it once spawn returns.
Status transitions (`pending``in_progress``done` / `blocked` /
`needs_user`) are the skill's main feedback loop: mutate the task
when the child covering it finishes, not when the child starts.
`blocked` and `needs_user` are not interchangeable — `blocked` is a
dependency the coordinator may be able to clear itself, while
`needs_user` marks a task that cannot move without a decision,
approval, or grant only the user can give. The distinction is
load-bearing: a coordinator that goes idle holding open tasks gets
nudged to pick them back up — even when children are still running, so
keep the matrix honest rather than expecting the reminder to wait for
an all-clear — and `needs_user` is what tells that nudge the stop was
deliberate. Pair it with `note` to record what is being asked for.
Use `tasks(action="update", task_id=..., child_ws_id=<ws_id>)` to link
a task to the child that owns it once spawn returns.
A final gotcha: parallel tool dispatch does NOT serialise reads
after writes in the same batch. If a skill issues an `update` and
@@ -297,11 +307,11 @@ and the coordinator's planning step is itself valuable.
tasks(action='add', title='...') × N # the plan, visible in the sidebar
for task in tasks:
spawn_workstream(skill=..., initial_message=task.brief)
tasks(action='update', task_id=task.id, notes='ws=<child_ws_id>')
tasks(action='update', task_id=task.id, note='ws=<child_ws_id>')
wait_for_workstream(ws_ids=[...], mode='all', timeout=...)
for child in children:
inspect_workstream(ws_id=child)
tasks(action='update', task_id=..., status='done', notes='result summary')
tasks(action='update', task_id=..., status='done', note='result summary')
→ synthesise
```
@@ -339,7 +349,7 @@ For a new coordinator skill:
A full end-to-end test isn't required for every skill; a
prepare-step unit test that asserts "given this initial message, the
first tool call is X with Y args" is usually sufficient to catch
persona drift without a real LLM in the loop.
framing drift without a real LLM in the loop.
---
+5 -6
View File
@@ -66,8 +66,7 @@ class "NullUI" as NullUI {
interface "LLMProvider" as LLMProvider <<Protocol>> {
+ provider_name: str {property}
+ get_capabilities(model) → ModelCapabilities
+ create_streaming(client, model, messages, ..., replay_reasoning_to_model) → Iterator[StreamChunk]
+ create_completion(client, model, messages, ..., replay_reasoning_to_model) → CompletionResult
+ create_streaming(client, model, messages, ..., cancel_ref, replay_reasoning_to_model) → Iterator[StreamChunk]
+ convert_tools(tools) → list[dict]
+ extract_reasoning_text(provider_blocks) → str
+ retryable_error_names: frozenset[str] {property}
@@ -149,9 +148,9 @@ class "ChatSession" as ChatSession {
+ handle_command(command: str)
+ resume(ws_id: str)
- _save_config()
- _stream_response(stream) → dict
- _create_stream_with_retry(msgs) → Stream (+ fallback)
- _try_stream(client, model, msgs) → Stream
- _stream_response(my_generation) → ModelTurnResult
- _model_turn_with_fallback(consumer, prepare_wire) → ModelTurnResult
- _model_turn_with_retry(lane, tracker, ...) → ModelTurnResult
- _execute_tools(tool_calls) → (results, feedback)
- _prepare_tool(tc) → item dict
- _prepare_mcp_tool(call_id, name, args) → item dict
@@ -177,7 +176,7 @@ class "HeadlessSession" as HeadlessSession {
+ send_headless(input, max_turns, ...)
- _override_system_prompt(content)
--
eval.py: non-streaming,
eval.py: drained single-shot turns,
records all tool calls
}
+2 -2
View File
@@ -84,8 +84,8 @@ end note
loop up to 3 turns (timeout budget)
Judge -> LLM : create_completion(\nmodel, judge_messages,\ntools=[read_file, list_directory])
LLM --> Judge : CompletionResult
Judge -> LLM : model_turn(lane, judge_turns,\ntools=[read_file, list_directory])\nvia drained create_streaming
LLM --> Judge : ModelTurnResult
alt tool_calls present (turn < 3)
Judge -> Judge : _exec_read_only_tool()
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6e2bfdf968e96f3720ed58674103288e2f57e9c056f5c479a57f37a849f3e69c
size 821878
+31 -1
View File
@@ -252,6 +252,7 @@ interface, or anyone who can reach it can search through your instance.
| Variable | Default | Description |
|----------|---------|-------------|
| `WORKSPACE_MOUNT` | empty volume | Host directory bind-mounted at `/workspace` for the model to read/write |
| `TURNSTONE_WORKSPACE` | `/workspace` (image env) | Directory named as the user's workspace in the model's tool descriptions; informational only — see [Working directory](#working-directory) |
| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tool calls (dev only) |
| `MCP_CONFIG` | — | Path to an MCP server config file |
| `TURNSTONE_IMAGE_TAG` | `latest` | ghcr.io image tag — production stack |
@@ -260,7 +261,7 @@ interface, or anyone who can reach it can search through your instance.
Both stacks install all entry points into a single image (`turnstone`,
`turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`,
`turnstone-eval`, `turnstone-doctor`):
`turnstone-eval`, `turnstone-optimizer`, `turnstone-doctor`):
```bash
docker compose build # build the dev image
@@ -276,6 +277,35 @@ docker compose build --no-cache # rebuild from scratch
| `workspace` | `/workspace` (unless `WORKSPACE_MOUNT` is set) |
| `caddy-data` / `caddy-config` | Caddy's local CA and config (dev stack) |
## Working directory
Node processes run with `/data` as their working directory (the image's
`WORKDIR`), and that is where the model's shell commands execute and
relative file paths resolve — **not** `/workspace`. The shell and file
tool descriptions state both paths (the working directory, and the
workspace named by `TURNSTONE_WORKSPACE`), so the model knows to look in
`/workspace` for your files without being told each session.
To make tools start inside the mount instead, override the working
directory on the node services:
```yaml
services:
turnstone-node:
working_dir: /workspace
```
Two caveats before overriding:
- **SQLite fallback**: when a node runs without PostgreSQL, its fallback
database `.turnstone.db` is created in the process working directory.
Changing `working_dir` on an existing SQLite-fallback deployment makes
the node create a fresh database inside the mount and your prior state
appears lost (it is still in the `turnstone-data` volume under `/data`).
The stock compose stacks use PostgreSQL and are unaffected.
- Migrations (`entrypoint.sh`) run in the same working directory, so the
same SQLite caveat applies to them.
## Cleanup
```bash
+55 -24
View File
@@ -1,11 +1,19 @@
# Evaluation and Prompt Optimization (turnstone-eval)
# Evaluation and Prompt Optimization (turnstone-eval, turnstone-optimizer)
`turnstone-eval` is the evaluation and prompt optimization system for turnstone. It
runs test cases against the LLM, scores tool call sequences against expected
actions, and optionally uses a multi-agent pipeline to optimize the developer
prompt and tool descriptions.
Evaluation for turnstone is split into two commands:
Source: `turnstone/eval.py`
- **`turnstone-eval`** — the measurement substrate. Runs test cases against the LLM
and scores tool call sequences against expected actions. A single measurement pass,
no self-modification.
- **`turnstone-optimizer`** — the prompt/tool optimizer. Loops over the measurement
substrate, using a multi-agent pipeline (analyst, optimizer, observer, diversifier,
tool optimizer) to edit the developer prompt and tool descriptions so more tests pass.
The dependency is strictly one-way: the optimizer consumes the eval substrate; the
substrate never depends on the optimizer.
Source: `turnstone/eval/core.py` (measurement substrate), `turnstone/eval/cli.py`
(the `turnstone-eval` CLI), `turnstone/optimizer.py` (the `turnstone-optimizer` CLI).
---
@@ -27,8 +35,8 @@ This approach (inspired by [Learning to Self-Evolve](https://arxiv.org/abs/2603.
prevents irrecoverable collapse from bad edits — UCB naturally backtracks to
high-scoring ancestors instead of following a linear chain.
When optimization is disabled (`--no-optimize`), only steps 2-4 execute
(a single iteration evaluating the root node).
The `turnstone-eval` command (or `turnstone-optimizer --no-optimize`) executes only
steps 2-4: a single measurement pass over the root prompt, no optimization.
---
@@ -452,30 +460,46 @@ structure is:
## CLI Usage
The entry point is `turnstone-eval` (installed as a console script) or
`python -m turnstone.eval`.
Two console scripts (installed as entry points), or the equivalent `python -m`
invocations:
- `turnstone-eval` / `python -m turnstone.eval.cli` — measure only.
- `turnstone-optimizer` / `python -m turnstone.optimizer` — optimize.
### Measure (`turnstone-eval`)
```
turnstone-eval tests.json # evaluate + optimize
turnstone-eval tests.json --no-optimize # evaluate only (single iteration)
turnstone-eval tests.json --n-runs 5 --max-iter 10 # more thorough evaluation
turnstone-eval tests.json --prompt custom.txt # start from a custom prompt
turnstone-eval tests.json --optimize-tools # optimize tool descriptions only
turnstone-eval tests.json --diversify 10 # test with prompt variants
turnstone-eval tests.json -v # verbose per-turn logging
turnstone-eval tests.json # one measurement pass, print scores
turnstone-eval tests.json --prompt custom.txt # measure a custom prompt
turnstone-eval tests.json --n-runs 5 # more runs per case
turnstone-eval tests.json --parallel 4 # run cases across 4 workers
turnstone-eval tests.json -v # verbose per-turn logging
```
### Multi-model setup (local test model, cloud optimizer)
### Optimize (`turnstone-optimizer`)
```
turnstone-eval tests.json \
turnstone-optimizer tests.json # evaluate + optimize
turnstone-optimizer tests.json --no-optimize # single pass, no optimization
turnstone-optimizer tests.json --n-runs 5 --max-iter 10 # more thorough optimization
turnstone-optimizer tests.json --prompt custom.txt # start from a custom prompt
turnstone-optimizer tests.json --optimize-tools # optimize tool descriptions only
turnstone-optimizer tests.json --diversify 10 # test with prompt variants
```
#### Multi-model setup (local test model, cloud optimizer)
```
turnstone-optimizer tests.json \
--base-url http://localhost:8000/v1 \
--optimizer-base-url https://api.anthropic.com \
--optimizer-model claude-sonnet-4-6 \
--analyst-model claude-opus-4-6
```
### All Options
### Measurement Options
Accepted by **both** commands.
| Flag | Default | Description |
|-------------------------|----------------------------|-------------|
@@ -484,19 +508,26 @@ turnstone-eval tests.json \
| `--model` | auto-detect | Model name. Auto-detected from the API if not specified. |
| `--prompt` | turnstone built-in prompt | Path to initial prompt text file. |
| `--n-runs` | from tests.json or 3 | Number of runs per test case. |
| `--max-iter` | 5 | Maximum optimization iterations. |
| `--no-optimize` | false | Run evaluation only (sets max-iter to 1). |
| `--temperature` | 0.7 | Sampling temperature. |
| `--max-tokens` | 32768 | Max completion tokens. |
| `--reasoning-effort` | `medium` | Reasoning effort: `low`, `medium`, or `high`. |
| `--context-window` | 131072 | Context window size. |
| `--output` | `eval_results.json` | Output results file path. |
| `-v`, `--verbose` | false | Show detailed per-turn logging. |
| `--explore-constant` | 1.414 (sqrt(2)) | UCB exploration constant C. |
| `--test-timeout` | 300 | Per-test timeout in seconds. |
| `--suite-timeout` | 0 (unlimited) | Total suite timeout in seconds. |
| `--no-fast-fail` | false | Disable early termination on all-zero initial runs. |
| `--parallel` | 1 (serial) | Parallel workers (0=auto, N=use N workers). |
### Optimizer Options
Accepted by **`turnstone-optimizer`** only.
| Flag | Default | Description |
|-------------------------|----------------------------|-------------|
| `--max-iter` | 5 | Maximum optimization iterations. |
| `--no-optimize` | false | Run a single measurement pass (sets max-iter to 1). |
| `--explore-constant` | 1.414 (sqrt(2)) | UCB exploration constant C. |
| `--suite-timeout` | 0 (unlimited) | Total suite timeout in seconds. |
| `--optimizer-model` | same as `--model` | Model for prompt optimization. |
| `--optimizer-base-url` | same as `--base-url` | Base URL for optimizer model. |
| `--observer-model` | same as optimizer | Model for meta-optimization (observer). |
+14 -6
View File
@@ -13,7 +13,7 @@ The permission model has two layers:
1. **Scopes** (legacy) — `read`, `write`, `approve`. Checked by `AuthMiddleware`
on every request based on URL path classification.
2. **Permissions** (granular) — 15 permission strings checked per-endpoint by
2. **Permissions** (granular) — named permission strings checked per-endpoint by
`require_permission()`.
**Built-in roles** (seeded by migration 008):
@@ -24,7 +24,11 @@ The permission model has two layers:
| operator | read, write, workstreams.create, workstreams.close |
| viewer | read |
Custom roles can be created with any subset of the 15 valid permissions.
Custom roles can be created with any subset of the valid permissions.
The `persona.create` / `persona.read` / `persona.write` family gates
persona administration; migration `063` seeds all three onto
`builtin-admin`, and any role can be granted them through the standard
role and permission-override editors.
**Auth flow:**
1. User logs in (password or API token) → `_load_user_permissions()` aggregates
@@ -127,9 +131,12 @@ Per-LLM-request token and tool call metrics:
LLM response with prompt/completion tokens, cache tokens, tool call count,
model, ws_id
- **Prompt caching**: Anthropic automatic caching (`cache_control: ephemeral`)
and OpenAI extended retention (`prompt_cache_retention: 24h` for GPT-5.x)
are enabled by default. `cache_creation_tokens` and `cache_read_tokens` are
tracked per request in `usage_events` and surfaced in the Usage admin tab
and OpenAI caching are enabled by default. Pre-5.6 GPT-5 models request
`prompt_cache_retention: 24h`; GPT-5.6 uses
`prompt_cache_options: {"ttl": "30m"}`. GPT-5.6 cache writes use the
provider's 1.25× input-token rate. `cache_creation_tokens` and
`cache_read_tokens` are tracked per request in `usage_events` and surfaced
in the Usage admin tab
- **Querying**: `GET /v1/api/admin/usage` with `group_by` (day/hour/model/user)
and time range filtering — includes cache token aggregates
- **Prometheus**: `turnstone_tokens_total{type="cache_creation|cache_read"}`
@@ -177,6 +184,7 @@ All under `/v1/api/admin/` (requires `approve` scope + granular permission).
| Orgs | 3 (list, get, update) | `admin.orgs` |
| Tool Policies | 4 (CRUD) | `admin.policies` |
| Skills | 4 (CRUD) | `admin.skills` |
| Personas | 4 (list, create, get, edit/archive) | `persona.read` / `persona.create` / `persona.write` |
| Schedules | 6 (CRUD + runs) | `admin.schedules` |
| Watches | 3 (list, create, cancel) | `admin.watches` |
| Usage | 1 (aggregated query) | `admin.usage` |
@@ -222,7 +230,7 @@ Both Python and TypeScript console SDKs expose governance methods:
- **Privilege escalation prevented**: `admin_assign_role` blocks self-assignment
and requires caller to hold a superset of the target role's permissions
- **Permission validation**: Role create/update validates permissions against
a 15-item allowlist (`_VALID_PERMISSIONS`)
the permission allowlist (`_VALID_PERMISSIONS`)
- **Self-deletion blocked**: `admin_delete_user` rejects attempts to delete
your own account (matching the self-assignment guard on role endpoints)
- **Field allowlists**: Storage `update_*` methods filter fields against
+8 -2
View File
@@ -249,8 +249,14 @@ are withheld from the live surfaces (a reused call_id must never ride a stale
`approve` into Smart Approvals) but still persist with
`user_decision = "superseded"` so the audit trail records the judge's answer.
Sub-agents (plan agent, task agent) are exempt from intent validation -- they
always get full tool visibility without judge evaluation.
Sub-agent (task agent) tool calls are judge-gated too. Each runs the same
intent pipeline as its own `agent_gate` generation, grounded in that sub-agent's
own trajectory -- its task prompt is the delegation contract the operator
approved, so "does this call serve the task" is the right local question.
Agent-gate generations never occupy the main loop's supersede slot (parallel
siblings would otherwise make each other's verdicts look stale); per-cycle
generation checks enforce staleness instead, and `judge.cancel_on_approval`
fires per gate exactly like the main loop.
---
+65 -4
View File
@@ -17,8 +17,9 @@ The MCP server admin form exposes three authorization modes ("Multitenant Author
| `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. |
| `oauth_obo` *(sign-in passthrough)* | Each user's Turnstone **org sign-in** (OIDC) mints a per-server access token on demand — no separate per-server consent. One captured credential per user covers every `oauth_obo` server. | Enterprise deployments where the identity provider governs access (Entra, Keycloak) and you want zero per-user connect clicks. See the dedicated section below. |
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.
Switching `auth_type` away from `oauth_user` / `oauth_obo` **deletes** that server's per-user rows (consents / minted cache) — see the transition table below. Switching back later starts clean: users re-consent (or re-mint) on next use. The admin **bulk-revoke** / **flush cache** affordance clears rows without an auth-type change.
---
@@ -65,6 +66,59 @@ Keep this in `config.toml` rather than environment variables. An in-process LLM
---
## `auth_type=oauth_obo` — single-credential sign-in passthrough
Where `oauth_user` makes each user complete a **separate** browser consent per MCP server, `oauth_obo` reuses the user's Turnstone **org sign-in** (OIDC). Turnstone captures one refresh credential per user at login and, on each tool call, mints a short-lived access token scoped to that server's audience. There is no per-server connect step, and one credential covers every `oauth_obo` server. This is the right shape when your identity provider already governs who may reach each backend (an Entra tenant with Entra-protected MCP servers; a Keycloak realm with token exchange).
Access is governed **downstream** by the IdP: a user can only mint a token for a server their delegated permissions allow. Removing that grant at the IdP cuts the user off regardless of their Turnstone state.
### Deployment configuration (`[oidc]` in `config.toml`)
`oauth_obo` requires OIDC SSO to be configured (it is the credential source), plus:
```toml
[oidc]
# ... your existing issuer / client_id / client_secret ...
capture_user_credential = true # persist the IdP refresh token at login
obo_grant_profile = "entra" # "entra" | "rfc8693" — how tokens are minted
```
- **`capture_user_credential`** (default `false`): when enabled, Turnstone appends `offline_access` to the login scopes and stores the returned refresh token, encrypted with the same `[security] mcp_token_encryption_key` as `oauth_user` tokens. **The encryption key is required** — Turnstone refuses to start with an `oauth_obo` row (or capture enabled) and no key.
- **`obo_grant_profile`** picks the mint mechanism (the IdP determines which one is valid; this is deployment-wide, not per-server):
- **`entra`** — redeems the user's refresh token directly for a token scoped to `<audience>/.default`. `oauth_scopes` on the server row is **not used** (the admin form rejects it under this profile).
- **`rfc8693`** — a refresh grant for a subject token, then an RFC 8693 token exchange for the server audience. Per-server `oauth_scopes` **are** sent on the exchange (some IdPs require the audience scope explicitly).
### Adding an `oauth_obo` server
In the admin MCP form, choose **Sign-in passthrough** and set **Audience** (required — the downstream resource the token is minted for, e.g. `api://<app-id>` on Entra or the client id on Keycloak). The client-id / secret / registration fields do not apply and are hidden.
`oauth_obo` servers are accepted only when **OIDC sign-in is configured and enabled** and `[oidc] obo_grant_profile` is a valid profile — the write is rejected otherwise, since a row that can never mint would surface to users as a permanent "please retry" that never heals.
### Identity-provider setup
**Entra (`obo_grant_profile = "entra"`):**
1. Turnstone's app registration must hold **delegated permissions** to each MCP server's exposed API, with **admin consent granted** (or the MCP app listed in Turnstone's `preAuthorizedApplications`).
2. Set the server row's Audience to the MCP app's Application ID URI (`api://<guid>`).
3. **Gotcha (verified):** admin-consent issued *immediately* after creating the app/service principal can silently skip a not-yet-propagated resource — the only symptom is `AADSTS65001` at mint time. Verify the delegated grant landed (`az ad app permission list-grants` / the portal's *API permissions* blade shows *Granted*), or grant it explicitly per resource. A missing grant surfaces in Turnstone as a re-login prompt on the affected server (same rail as a revoked credential), and the `mcp_server.oauth.obo_mint_rejected` log line carries the raw `AADSTS…` text.
**Keycloak / RFC 8693 (`obo_grant_profile = "rfc8693"`):**
1. Enable **standard token exchange** on Turnstone's client.
2. Grant the audience: add an audience client scope for each MCP client and attach it to Turnstone's client (optional scopes must be requested — set the server row's Scopes to that scope, or the exchange returns *"Requested audience not available"*).
3. Set the server row's Audience to the downstream client id.
### Revocation & custody
The captured credential is a single per-user secret that can mint for every `oauth_obo` server, so treat it like any long-lived credential:
- **Cut off one user:** unlink their OIDC identity in the admin console (**Users → OIDC identities → delete**). This revokes the captured credential **and** purges their minted cache rows, so future mints fail and cached tokens are dropped. (Warmed in-memory sessions on server nodes self-expire at the access-token TTL; there is no cross-node per-user session-kill.) Removing the user's access at the IdP is the authoritative cut-off.
- The same unlink also purges that user's synthetic `__model_obo__:` gateway-token rows and requests eviction from every registered host's in-process mint memo. Shared `entra_app` model tokens live under the `__app__` pseudo-user and are intentionally not user-deprovisioned; revoking the app credential prevents new mints, while a cached app bearer lasts until `expires_at`.
- **Flush a server's minted tokens** (e.g. after narrowing its audience): the server row's **flush cache** action drops all users' cached tokens for that server. This is **not** a revocation — users re-mint on next use from their still-valid sign-in. It is surfaced honestly (audit `mcp_server.oauth.obo_cache_flushed`, response `effect: cache_flush_remints`) so it is never mistaken for cutting access.
- Per-server revocation in the `oauth_user` sense does not exist for `oauth_obo` — the credential is issuer-scoped and IdP-governed. Revoke at the IdP.
> **Interim for Entra without OBO:** if you don't want host-side minting, admin consent + `preAuthorizedApplications` on each MCP app registration removes the second consent prompt for the plain `oauth_user` flow too (a tenant-config change, no Turnstone code). Tracked in issue #682. It does not remove the per-server connect clicks or per-(user, server) token custody — that is what `oauth_obo` is for.
---
## 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.
@@ -75,7 +129,7 @@ Keep this in `config.toml` rather than environment variables. An in-process LLM
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).
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). `oauth_obo` servers and synthetic model-auth rows are excluded: their rows are mint caches, not consents — deleting one only forces a re-mint — so the connections list hides them and the endpoint refuses them with `409` (revocation for sign-in passthrough happens at the identity layer: unlink the identity or revoke at the IdP).
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.
@@ -97,10 +151,13 @@ Additional indicators (circuit-breaker state, encryption-key mismatch) are expos
| 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_user``none` / `static` | — | Existing `mcp_user_tokens` rows are **deleted**: the tokens are bound to the auth model + URL active at consent time, and rows left behind could silently rebind if a row with the old name/URL reappears. Switching back to `oauth_user` later starts clean — users re-consent on next use. This is **not reversible**; the AS-side grants are untouched (revoke upstream via the AS if needed). |
| 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. |
| `oauth_user``oauth_obo` | — | The per-user rows are **deleted** on the flip (they mean different things: per-server AS refresh tokens vs. minted cache). `oauth_audience` and `oauth_scopes` mean different things in each model (a resource indicator vs. an IdP app identifier; AS-consent scopes vs. an rfc8693 exchange scope), so on a flip they **never carry** — each is taken from the request for the target model or set NULL. The admin console clears these fields when you change the auth type, so re-enter the correct values for the new mode; via the API, supply them explicitly (a flip into `oauth_obo` with no `oauth_audience` is rejected, and a non-empty `oauth_scopes` under the `entra` profile is rejected since that leg pins `<audience>/.default`). |
| `oauth_obo``none` / `static` | — | Minted cache rows are deleted. |
| `oauth_obo` **audience**, **URL**, or **`oauth_scopes`** changed | — | Minted cache rows are **deleted** (tokens are bound to the audience/URL/scopes at mint time), forcing a fresh mint — so an audience or scope narrowing takes effect immediately, not at token expiry. |
The orphan-by-default behavior is chosen so switching back to `oauth_user` is non-destructive. Bulk-revoke is the explicit cleanup path.
Every transition that changes what a stored row *means* deletes the rows outright — a stale consent or minted token must never be served under new semantics. There is no orphan-and-reactivate path.
---
@@ -113,5 +170,9 @@ The orphan-by-default behavior is chosen so switching back to `oauth_user` is no
| `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. |
| **`oauth_obo`**: every tool call fails, log shows `obo_misconfigured` | Server row has no Audience, or `obo_grant_profile` is unset/unknown | Set the Audience on the server row; set `[oidc] obo_grant_profile` to `entra` or `rfc8693`. |
| **`oauth_obo`**: `obo_mint_rejected` with `AADSTS65001` | Turnstone's app lacks the (admin-consented) delegated grant to this MCP app — often admin consent that didn't propagate | Grant + admin-consent the delegated permission for this resource; verify it shows *Granted*. See the Entra gotcha above. |
| **`oauth_obo`**: "Sign in to Turnstone again" on one server | Captured credential missing/rejected, or a Conditional Access challenge | User re-logs into Turnstone (re-captures the credential). If it persists, check the IdP grant / CA policy. |
| **`oauth_obo`**: tools don't appear at all for a user | User has not signed in since `capture_user_credential` was enabled (no credential captured) | User logs out and back in via OIDC so the refresh credential is captured. |
See also: `docs/operations/mcp-oauth-headless.md` for the cron / channel-driven run caveat.
+5
View File
@@ -75,6 +75,11 @@ This means the model always has its most relevant memories available without
explicit recall -- but can still use `memory(action='search')` for deeper
lookup.
The persona memory lever gates this pathway: a workstream whose persona
turns memory off receives no relevance injection at all -- the steps
above run only when memory is enabled for the session. See
[Personas](personas.md).
### Nudges
The metacognition layer can nudge the model to save memories at appropriate
+105 -9
View File
@@ -41,6 +41,7 @@ are set.
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
| `TURNSTONE_OIDC_REDIRECT_BASE` | Yes | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Without this, OIDC will refuse to start. The previous Host-header fallback was unsafe under permissive reverse proxies. |
| `TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS` | No | — | Comma-separated list of additional hostnames whose endpoints the IdP discovery document is allowed to reference. See [Cross-host endpoints](#cross-host-endpoints). |
| `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK` | No | `false` | Allow the issuer (and its discovered endpoints) to resolve to private/internal addresses — needed for a self-hosted IdP on an internal network. See [Self-hosted and internal IdPs](#self-hosted-and-internal-idps). |
All four required fields — issuer, client ID, client secret, and
`TURNSTONE_OIDC_REDIRECT_BASE` — must be set. If any are missing OIDC
@@ -76,17 +77,17 @@ IdP from redirecting the token-exchange POST (which carries
being aimed at internal services.
A few public IdPs legitimately split endpoints across hostnames. Google
is the canonical example:
and Microsoft Entra ID are the canonical examples:
| Field | Hostname |
|-------|----------|
| issuer | `accounts.google.com` |
| token_endpoint | `oauth2.googleapis.com` |
| jwks_uri | `www.googleapis.com` |
| userinfo_endpoint | `openidconnect.googleapis.com` |
| IdP | Issuer host | Cross-host endpoint(s) |
|-----|-------------|------------------------|
| Google | `accounts.google.com` | `oauth2.googleapis.com`, `www.googleapis.com`, `openidconnect.googleapis.com` |
| Microsoft Entra | `login.microsoftonline.com` | `graph.microsoft.com` (userinfo) |
Google's set is built in — operators using `https://accounts.google.com`
need no extra configuration.
Both sets are built in — operators using `https://accounts.google.com` or
`https://login.microsoftonline.com/<tenant>/v2.0` need no extra
configuration. (Entra's discovery document advertises `userinfo_endpoint`
on `graph.microsoft.com`, distinct from the issuer host.)
For other IdPs whose discovery document references a non-issuer host,
extend the allow-list explicitly:
@@ -99,6 +100,99 @@ The same scheme / no-userinfo / SSRF rules apply to allow-listed hosts —
this knob only relaxes the same-origin check, not the security gates.
Each entry is a hostname (no scheme, no path).
### Self-hosted and internal IdPs
By default Turnstone refuses an issuer whose hostname resolves to a
private or internal address:
```
OIDCError: endpoint URL resolves to non-public address (10.0.0.5): https://auth.example.site
```
This is SSRF hardening, not a licensing or product restriction: the OIDC
flow makes server-side HTTP requests (discovery, JWKS, token exchange),
and refusing non-public destinations keeps a mistyped or maliciously
steered issuer from aiming those fetches at internal services. For a
self-hosted IdP (Keycloak, Authentik, Dex, …) on a private network,
opt in explicitly in `config.toml`:
```toml
[oidc]
allow_private_network = true
```
or via `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true` (the env var wins
when both are set).
The opt-in admits private-range (RFC 1918), unique-local, CGNAT
(100.64/10 — tailnets), and loopback addresses. Link-local, multicast,
and reserved ranges stay refused even with the opt-in — cloud metadata
services (169.254.169.254) live there, and no legitimate IdP does. The
HTTPS requirement and the same-origin endpoint checks are unaffected.
This knob only affects the login-flow IdP configured here. OAuth
endpoints advertised by remote MCP servers are untrusted input and are
always held to the strict public-address rule.
### Model gateway credentials
The same OIDC registration can authenticate model gateways. A model definition
with `auth_mode = "entra_obo"` (Entra grant profile) or `auth_mode =
"rfc8693_obo"` (RFC 8693 token-exchange profile) redeems the driving user's
captured credential for its exact `obo_audience`; `auth_mode = "entra_app"`
uses the registration's client ID and secret with Entra client credentials.
All three bind the result through the provider SDK's native credential option
rather than injecting an override header. The grant mode is never inferred:
missing user context or a failed OBO mint cannot switch a delegated definition
to client credentials.
Each dynamic mode pairs with the grant profile whose dialect it names:
`entra_obo` and `entra_app` require `obo_grant_profile = "entra"`;
`rfc8693_obo` requires `obo_grant_profile = "rfc8693"`. The pairing is
enforced when a write chooses a `(auth_mode, obo_audience)` pair — a same-pair
edit of a row saved before the pairing rule keeps working — and at runtime a
mismatched legacy row refuses to mint with `cause=grant_profile_mismatch` and
no IdP traffic. RFC 8693 client-credentials is not implemented.
The delegated modes need the MCP encryption key, a credential captured for the
driving user, and delegated/admin-consented permission to the audience.
`rfc8693_obo` additionally carries `obo_scopes`, the space-separated scope
list its exchange leg requests: exchange-capable IdPs that gate audiences
behind optional scopes refuse the exchange without it ("Requested audience not
available"), which is why the scope-less Entra-named mode could never mint on
that profile (issue #955). Scopes are stored shape-checked only — whether a
value satisfies the IdP stays the IdP's call at mint time. Turning
`capture_user_credential` off later stops *new* captures but does not
invalidate credentials already stored, so existing users keep minting.
`entra_app` requires a confidential-client secret. Configure the permitted
resource IDs in the runtime setting `model.auth_audience_allowlist` before
saving dynamic model definitions. De-listing an audience later blocks every
write that would arm or re-aim a definition at it, but does not stop aliases
already configured from minting — disabling the row (the `admin.models` disarm
lever) is what stops minting. See
[Settings](settings.md#model-backend-authentication) for permissions, failure
policy, and lane identity rules.
An unrecognised `obo_grant_profile` is warned about at startup and **rejected
at the write choke points**: configuring an `oauth_obo` MCP server or a dynamic
model alias returns a 400 that echoes the configured value, so the typo is the
diagnosis. At runtime an unknown profile never mints — the mint legs resolve by
exact name; the full cause detail is logged once per audience, and every
affected call still logs its per-turn fallback or refusal naming the alias,
the target audience, and the last recorded cause (`cause=` — for example
`unsupported_grant_profile` or `oidc_not_enabled`) — so a pre-existing row
degrades loudly, with the reason visible mid-incident even after the
once-per-process line has rotated out of retained logs, rather than silently
swapping per-user attribution for the shared static key.
The `[security]` token encryption key is deployment-wide, not per-host: rows are
encrypted with `MultiFernet` and carry no key id, so every host that reads them
needs the same keyring. That includes the console, which mints for
coordinator-hosted sessions. A node that needs the key and lacks it refuses to
start; the console starts but withholds its coordinator subsystem and shows
the key requirement as the remediation error instead of failing silently at
call time.
### config.toml alternative
```toml
@@ -111,6 +205,8 @@ provider_name = "Google"
role_claim = "groups"
password_enabled = true
redirect_base = "https://app.example.com"
# Self-hosted IdP on an internal network (see "Self-hosted and internal IdPs")
allow_private_network = false
[oidc.role_map]
admin = "builtin-admin"
+173
View File
@@ -0,0 +1,173 @@
# Personas
A **persona** is a named, reusable bundle attached to a workstream **at
creation** that controls how its system message is composed and what
capability envelope it runs with. Personas answer a recurring operational
complaint: the default composition primes every session for heavy tool use,
and there was no per-workstream dial to launch a "just write prose" or
"evidence-first research" session.
A persona is exactly four levers — no more:
| Lever | What it does |
|---|---|
| **Base prompt** | Replaces the BASE module of the composed system message. *Only* BASE: ENV, CONTEXT, TOOLS, and POLICIES keep composing, so mandatory [prompt policies](governance.md) ride on top of every persona. Built-in personas source their prose from a repo file; operator personas store it inline — see [Where persona prompts live](#where-persona-prompts-live). |
| **Tool visibility** | Which tools the session advertises. Tri-state: *unrestricted* (tracks tool growth and MCP catalogs), *no tools* (the TOOLS prompt block self-suppresses and zero definitions go on the wire), or an *exact set* of names. Including `tool_search` in a set makes it **soft** — tools the model discovers through search join the visible set; omitting it makes the set **hard** (the search pathway is disabled entirely). On commercial providers a soft set costs one prompt-cache re-prime per `tool_search` expansion, since each expansion rewrites the wire tool set and recomposes the prompt. |
| **MCP** | Whether the workstream talks to MCP at all. **Session-wide**: off means no MCP tools for the persona's own hands *or* for in-process task agents, no resource/prompt catalogs, and no listener registrations. This lever expresses infrastructure intent, not behavior shaping. |
| **Memory** | Whether the persona's **own hands** get memory: recalled-memory injection into the prompt, memory-directed metacognitive nudges, and the `memory` tool. Task agents keep their own envelope, and compaction spill/markers are session mechanics that are never persona-gated. An exact tool set that hides `memory` also mutes those nudges, and the compaction-resume pointer follows `recall`'s visibility. |
Visibility is behavior shaping, **not** a security boundary: any tool call
that does reach the wire still clears the same approval, judge, and policy
machinery as always. RBAC and tool policies remain the enforcement layers.
## Snapshot semantics — resolve once, stamp forever
The persona is resolved **once**, at workstream creation, and stamped into
`workstream_config` as five keys (`persona`, `persona_prompt`,
`persona_tools`, `persona_mcp`, `persona_memory`). From then on the session
reads only the stamp:
- **Editing or archiving a persona never changes an existing workstream.**
Rehydrate, resume, and post-compaction resume all run from the stamp.
A mid-session REPL `/resume` adopts the target workstream's stamp for
prompt, tools, and memory; for the MCP lever it can only narrow in
place — adopting an MCP-off stamp drops the live MCP surface, while
adopting an MCP-on stamp into a session whose persona dropped MCP at
construction is refused with an error telling you to reopen the
workstream fresh.
- A workstream outlives its persona — an archived persona keeps labelling
the workstreams stamped with it.
- A partial or unparseable stamp is treated as corruption: session
construction fails loudly rather than silently falling back to a default
envelope the operator never chose.
- Workstreams created before personas existed carry no stamp and keep
legacy behavior, byte-identical to the `engineer` / `orchestrator`
defaults below — with one exception: pre-1.7 workstreams that had
`creative_mode` set are converted by migration `063` into full
`writer` stamps, so they resume as writing sessions rather than as
legacy defaults.
- Forking (`resume_ws` on create) resumes the source's stamped persona; the
fork does not re-resolve.
## Seed personas
Migration `063` seeds six personas. The two per-kind **defaults** carry no
overrides at all, so a zero-touch launch behaves exactly as it did before
personas existed:
| Persona | Kind | Base prompt | Tools | MCP | Memory |
|---|---|---|---|---|---|
| `engineer` *(default)* | interactive | stock | unrestricted | on | on |
| `orchestrator` *(default)* | coordinator | stock | unrestricted | on | on |
| `scribe` | interactive | custom (faithful structuring of given material) | none | off | off |
| `researcher` | interactive | custom (evidence-first) | `read_file`, `search`, `web_fetch`, `web_search`, `recall`, `memory`, `tool_search` (soft) | off | on |
| `writer` | interactive | custom (creative writing partner — replaces the removed `/creative`) | none | off | on |
| `executive` | coordinator | custom (delegate, interrogate plans, judge outcomes) | spawn/inspect/lifecycle tools plus `memory`: `spawn_workstream`, `spawn_batch`, `send_to_workstream`, `wait_for_workstream`, `inspect_workstream`, `list_workstreams`, `list_nodes`, `close_workstream`, `cancel_workstream`, `memory` (hard) | off | on |
Notes:
- `scribe` turns memory off deliberately: recalled memories would
contaminate faithful summarization with unrelated context.
- `researcher`'s set is soft (includes `tool_search`): it starts with
read and evidence tools but can pull in others on demand — e.g. load
`bash` to run a snippet and verify a calculation. It is evidence-first,
not sandboxed; any escalated tool still hits the normal approval path.
- Coordinator sessions do not merge MCP today, so the MCP lever on
coordinator personas is forward-compatible bookkeeping; it bites on
interactive workstreams.
## Where persona prompts live
Prompt source is explicit in the persona row — two nullable columns, never both empty:
| `base_prompt_file` | `base_prompt` | Meaning |
|---|---|---|
| set (e.g. `scribe.md`) | — | **built-in**: prose lives in `prompts/personas/<file>`, code-owned and PR-reviewed |
| set | set | built-in with an **operator override** layered on top (the inline text wins) |
| — | set | **operator** persona, inline prose |
A `CHECK` forbids the both-empty row, so resolution is a plain coalesce —
`base_prompt ?? load(base_prompt_file)` — with no implicit "inherit the default"
branch in application logic. `base_prompt_file` is set only by the migration/code
(the admin API never exposes it): it marks a persona as built-in and blocks
archive, so `engineer` and `orchestrator` can't be removed. To customise a
built-in, set `base_prompt` on it (clear it to revert), or create your own persona.
The resolved prompt is **frozen into the workstream at creation** — later edits to
a built-in's file or an operator's row never change a running workstream; only new
ones pick up the change. "No persona" is not a state: every workstream is stamped,
and an empty `persona=` resolves to the kind's `is_default` (`engineer` /
`orchestrator`).
## Choosing a persona
Every creation surface takes an optional persona; empty always means the
kind's default (or plain legacy behavior on a database with no personas
seeded):
- **Web/console**: the persona select on the console launcher, the server
webui's new-workstream dialog, and the dashboard composer. Selecting a
persona requires **no** `persona.*` permission — the picker feed
(`GET /v1/api/personas`) is authenticated-only and returns display fields.
- **API/SDK**: `CreateWorkstreamRequest.persona` (Python:
`create_workstream(persona=...)`; TypeScript: `{ persona: ... }`).
- **CLI**: `turnstone --persona <name>`. Unknown or disabled names error at
startup. `--resume` ignores `--persona` and adopts the resumed
workstream's stamp.
- **Coordinator spawn**: `spawn_workstream` / `spawn_batch` take a
`persona` argument, validated when the coordinator prepares the spawn
and re-checked by the node that creates the child (children are always
interactive-kind). Omitted means the interactive **default** — a child
never inherits its parent coordinator's persona.
- **Sub-agents**: `task_agent` takes a `persona` argument setting the
sub-agent's identity and capability envelope (resolved against
interactive-kind personas, frozen into the task at prep). Omitted keeps
the default autonomous task-agent identity — never the parent's persona.
## How agents discover personas
Agents are told, not expected to guess: the live persona list (enabled,
interactive-kind — children and sub-agents are always interactive) is
injected into the `persona` parameter description of `task_agent`,
`spawn_workstream`, and `spawn_batch` whenever the session's tool surface
is rendered — session start, MCP catalog change, model-registry reload.
Each entry carries the name, the default marker, and the persona's
one-line description so the model can pick by purpose (descriptions drop
out past 25 personas; the name list always enumerates completely).
A persona created after that render is still reachable — pass its name.
Every resolve failure enumerates the names currently valid for the kind,
so a stale list (or a typo) self-corrects on the next attempt.
Resolution is forgiving on all surfaces (they share one rule):
- names match case-insensitively (`Writer` resolves `writer`);
- an input that uniquely matches a persona's **display name**
(case-insensitive, among the kind's enabled personas — display names are
not unique, and a same-label persona of another kind neither blocks nor
wins) resolves to that persona; an ambiguous match errors, listing the
candidate slugs;
- whatever variant matched, the stamped identity, approval chrome, and
wire always carry the canonical `name` slug.
## Authoring (console)
Personas are managed in the console's **Manage → Governance → Personas**
tab. The admin shelf exposes exactly the four levers plus the kind
list, the default marker, and archive. Rules:
- `name` is an immutable lowercase slug — and the identifier agents and
the CLI launch the persona by (`persona=` on the spawn tools,
`--persona` on the CLI); the create shelf says so under **Name**.
`display_name` is a list label, editable any time, and deliberately
not an identifier (a unique display name happens to resolve, as a
forgiveness fallback — don't design workflows around it).
- Exactly one default per kind, storage-enforced: flipping the flag on a
successor demotes the incumbent atomically, defaults are single-kind,
and a default cannot be archived.
- **Archive only** — there is no delete verb, so every stamped
workstream's provenance stays explicable.
RBAC: `persona.create` / `persona.read` / `persona.write` gate the admin
CRUD (`/v1/api/admin/personas`); all three are granted to `builtin-admin`
by migration `063`, and other roles opt in via role permission overrides.
+2 -2
View File
@@ -69,7 +69,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|----------|--------|---------|
| **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` |
| | `dashboard()` | `DashboardResponse` |
| | `create_workstream(*, name, model, auto_approve, skill, initial_message, attachments)` | `CreateWorkstreamResponse` |
| | `create_workstream(*, name, model, auto_approve, skill, persona, initial_message, attachments)` | `CreateWorkstreamResponse` |
| | `close_workstream(ws_id)` | `StatusResponse` |
| **Attachments** | `upload_attachment(ws_id, filename, data, *, mime_type=...)` | `UploadAttachmentResponse` |
| | `list_attachments(ws_id)` | `ListAttachmentsResponse` |
@@ -100,7 +100,7 @@ Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose:
| | `workstreams(*, state, node, search, sort, page, per_page)` | `ClusterWorkstreamsResponse` |
| | `node_detail(node_id)` | `NodeDetailResponse` |
| | `snapshot()` | `ClusterSnapshotResponse` |
| | `create_workstream(*, node_id, name, model, initial_message, skill)` | `ConsoleCreateWsResponse` |
| | `create_workstream(*, node_id, name, model, initial_message, skill, persona)` | `ConsoleCreateWsResponse` |
| **Schedules** | `list_schedules()` | `ListSchedulesResponse` |
| | `create_schedule(*, name, schedule_type, initial_message, ...)` | `ScheduleInfo` |
| | `get_schedule(task_id)` | `ScheduleInfo` |
+103 -1
View File
@@ -54,6 +54,108 @@ When a per-model override is `NULL` (empty in the UI), the global default is
used. Switching models via `/model <alias>` re-resolves sampling parameters
from the new model's overrides or global defaults.
### Model backend authentication
Model definitions support four backend credential modes:
| `auth_mode` | Identity sent to the model gateway |
|-------------|------------------------------------|
| `static` | The definition's stored `api_key`. |
| `entra_obo` | A caller-delegated Entra access token minted from that user's captured OIDC credential. |
| `entra_app` | A shared app-identity token minted with Turnstone's OIDC client credentials. |
| `rfc8693_obo` | A caller-delegated access token minted from the captured credential via RFC 8693 token exchange, requesting the definition's `obo_scopes`. |
Dynamic modes require an exact `obo_audience` resource identifier. Before an
admin can save one, an operator must add that literal audience to
`model.auth_audience_allowlist` (comma- or newline-separated). Wildcards and
base-URL host matching are intentionally unsupported, and a row whose
effective mode is `static` refuses to store a new non-empty `obo_audience` on
either create or update — an audience cannot be staged for a later flip
(clearing a stale value, or re-saving it unchanged, stays allowed).
`obo_scopes` follows the same staging rule with the mode set inverted: only
`rfc8693_obo` reads it, so every other effective mode refuses to store a new
non-empty value, while clearing or re-saving one unchanged stays open. The
value itself is optional and shape-checked only — whether it satisfies the
IdP is decided at mint time. On a row that is (or becomes) dynamic, every
change except the tuning fields — context window, temperature, max tokens,
reasoning effort, and the two reasoning-persistence toggles — also requires
`admin.mcp`; service tokens do not bypass this capability-escalation gate.
The one exception is de-escalation: a save whose only gated change is
switching `enabled` off is a pure disable, needs only `admin.models`, and
skips validation — a de-listed audience must never block disarming its own
row. The gate is deny-by-default: a field counts as auth-relevant unless it
is provably neutral, so re-enabling a disabled dynamic row, re-pointing its
`base_url`, or swapping its provider or alias all escalate.
Validation runs in two tiers, matching the MCP `oauth_obo` write rules. Row
validity — the audience is allow-listed — applies to every gated write that
touches a dynamic configuration, so a revoked audience can be neither silently
re-pointed at a new `base_url` nor re-armed by an enable flip. Deployment
posture — the token encryption key installed, single sign-on configured, and
the grant profile valid and able to carry the mode — is checked when a write
*chooses* the mode/audience pair and when it re-enables a disabled dynamic
row (arming is the flip that resumes minting, so it must meet what minting
needs); other edits to an existing row stay open if the deployment's posture
changed after it was saved (its mints warn at runtime instead). Refusals name
their cause and echo the configured value.
One asymmetry to be aware of: the write path counts a transient discovery
outage (`enabled=false`, retryable) as configured, but the mints themselves
require discovery to have completed — a config saved during an outage starts
minting only once any authenticated request heals discovery. Until then calls
warn and follow the fail-open/fail-closed policy above.
Every dynamic mode pairs with exactly one grant profile: `entra_obo` and
`entra_app` require `[oidc] obo_grant_profile = "entra"`, and `rfc8693_obo`
requires `"rfc8693"`. The pairing is enforced at the posture tier, so a row
saved before the rule existed keeps accepting same-pair edits; its mints
refuse at runtime with `cause=grant_profile_mismatch` and no IdP traffic.
Judge, output-guard, perception, utility, and sub-agent lanes inherit the
session's effective user for the delegated modes. The perception memo is
partitioned by that principal as well as alias and content hash, so a result
authorized as one user cannot be served to another. Scheduled and wake-driven
work retains the workstream owner even when no user is connected. Eval and
optimizer lanes are registry-less development tools and therefore do not use
dynamic model authentication.
`entra_app` is an explicit model-definition choice; Turnstone never changes a
failed or ownerless delegated call into a client-credentials grant. A
delegated-mode call with no effective user always refuses. A dynamic alias
without a real static key also always refuses instead of issuing its
SDK-construction placeholder. When a real static key is explicitly configured,
mint failures may use it by default; set `model.auth_fail_closed = true` to
prohibit even that fallback. A refusal is not routed through the model
fallback chain.
Dynamic token caches are encrypted in `mcp_user_tokens`, shared across nodes,
and memoized on each host. Unlinking a user's OIDC identity purges their
delegated-mode rows and memo entries. `entra_app` rows belong to the shared
`__app__` identity and are not user-deprovisioned; after client-credential
revocation, an already-minted app bearer remains usable until its recorded
expiry.
`obo_audience` and `obo_scopes` are literal and capped at 2048 characters
each. Environment-variable expansion is deliberately not applied, so the
allow-list decision cannot vary by node or expand beyond the persisted
boundary.
### Responses output controls (per-model)
Models whose capability table declares Responses output controls expose two
additional fields in the Models create/edit shelf:
| Field | Stored capability | Values | Effect |
|-------|-------------------|--------|--------|
| Output verbosity | `verbosity` | `low`, `medium`, `high` | Controls answer length independently of reasoning effort. |
| Reasoning mode | `reasoning_mode` | `standard`, `pro` | Selects standard or higher-compute Pro execution without changing the model ID. |
An empty selection means provider default and omits the capability key. Known
GPT-5.6 models inherit support from the built-in table without persisting
redundant support flags. An OpenAI-compatible model pinned to the Responses API
can opt in with the `supports_verbosity` and `supports_pro_mode` capability
tiles. Chat Completions and non-Responses providers do not surface or submit
these controls.
**Removed settings:** `model.name` and `model.context_window` have been removed
from ConfigStore. Model names and context windows are now configured per-model
in the Models tab. A startup warning is logged if these keys appear in
@@ -107,7 +209,7 @@ initialization:
| Section | Settings |
|---------|----------|
| `model` | default_alias, temperature, max_tokens, reasoning_effort, task_alias, task_effort |
| `model` | default_alias, auth_audience_allowlist, auth_fail_closed, temperature, max_tokens, reasoning_effort, task_alias, task_effort |
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
| `server` | workstream_idle_timeout, max_workstreams |
+77 -18
View File
@@ -1,6 +1,6 @@
# Tools Reference
turnstone exposes 16 built-in tools plus any number of external MCP tools to the
turnstone exposes 17 built-in tools plus any number of external MCP tools to the
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
MCP tools are discovered from configured MCP servers at startup by
@@ -28,13 +28,19 @@ schema plus turnstone-specific metadata keys:
}
```
**Metadata keys** (stripped before sending the schema to the model):
**Metadata keys** (stripped before sending the schema to the model; the full
set lives in `_META_KEYS` in `turnstone/core/tools.py`):
| Key | Type | Meaning |
|----------------|------|---------|
| `task_agent` | bool | Tool is available to task sub-agents. |
| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). |
| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. |
| Key | Type | Meaning |
|------------------|------|---------|
| `task_agent` | bool | Tool is available to task sub-agents. |
| `coordinator` | bool | Tool is available to coordinator sessions. Without `interactive: true` alongside it, this reads as coord-only and the tool is stripped from interactive sessions. |
| `interactive` | bool | Opt a `coordinator: true` tool back into interactive sessions (dual-kind tools like `memory`). |
| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). |
| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. |
| `kind_variants` | dict | Per-kind description / parameter-schema overlays so each session kind sees only the surface it can use (see `memory.json`). |
| `cwd_note` | str | Sentence appended to the description at session build time with `{working_dir}` substituted — declare on tools whose semantics depend on the process working directory (see `bash.json`, `apply_cwd_context`). |
| `workspace_note` | str | Companion sentence naming the operator-configured workspace directory, `{workspace_dir}` substituted; dropped when no workspace is configured. |
---
@@ -44,10 +50,10 @@ schema plus turnstone-specific metadata keys:
| Name | Description |
|---------------------|-------------|
| `TOOLS` | All 28 loaded built-in tool definitions (interactive + coordinator union). Sessions send a kind-specific subset (`INTERACTIVE_TOOLS` or `COORDINATOR_TOOLS`). |
| `TOOLS` | All 29 loaded built-in tool definitions (interactive + coordinator union). Sessions send a kind-specific subset (`INTERACTIVE_TOOLS` or `COORDINATOR_TOOLS`). |
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
| `TASK_AUTO_TOOLS` | Set of all tool names with `auto_approve: true` -- used by task-agent sub-sessions to skip confirmation for matching available tools. |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 28 built-in tool names (interactive + coordinator union). Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 29 built-in tool names (interactive + coordinator union). Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
---
@@ -65,7 +71,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
- Parses the JSON arguments (with fallback for malformed JSON).
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
to the correct parameter.
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 16
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 17
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
the generic `_prepare_mcp_tool()` handler for MCP tools.
- Validates arguments and builds a preview dict containing:
@@ -125,6 +131,9 @@ Each item's `execute` callable is invoked:
- `web_fetch` -- fetches a URL (SSRF-protected, but makes network requests)
- `web_search` -- web search via self-hosted SearxNG (makes network requests)
- `task_agent` -- spawns an autonomous sub-agent
- `open_preview` -- **URL targets only** (network access, gated like `web_fetch`);
file-path and `attachment:` targets are local reads and run unprompted like
`read_file`
Note: The JSON schema metadata key `auto_approve` controls membership in
`TASK_AUTO_TOOLS` (used for task agent sub-sessions). The actual runtime
@@ -157,6 +166,7 @@ Every tool defines a `primary_key`. The mapping is:
| `search` | `query` |
| `web_fetch` | `url` |
| `web_search` | `query` |
| `open_preview` | `target` |
| `task_agent` | `prompt` |
| `memory` | `name` |
| `recall` | `query` |
@@ -285,7 +295,7 @@ Fetch a URL and extract specific information from it.
| `url` | string | yes | The URL to fetch (must start with `http://` or `https://`). |
| `question` | string | yes | What to extract or answer from the page content. |
- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Protected against SSRF (blocks private/internal IPs).
- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Every redirect hop is SSRF-screened before it is requested. Private/internal addresses are refused by default; enable `tools.allow_private_network` (console Settings → Tools) to make them approvable for self-hosted setups whose services live on the local network — the approval prompt marks such requests, and a public site redirecting into private space is refused regardless.
- **Auto-approve**: No -- requires user confirmation (makes network requests).
- **Agent availability**: `task_agent`.
@@ -345,6 +355,39 @@ It reports the score scale, whether the endpoint cleanly separates relevant from
---
### open_preview
Show the user rich content in a preview pane beside the conversation.
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `target` | string | yes | An http(s) URL, a file path, or `attachment:<id>` for a file attached to the conversation. |
| `kind` | string | no | Rendering override: `web`, `pdf`, `image`, `table`, `text`, or `markdown`. Detected from the content when omitted. |
| `title` | string | no | Pane header title. Defaults to the page title, filename, or URL. |
- **What it does**: Resolves the target to bytes (URLs fetch through the same
SSRF-guarded path as `web_fetch`, screened per redirect hop, honoring the
same `tools.allow_private_network` opt-in), classifies the
content, stores it content-addressed against the workstream, and opens the
frontend preview pane beside the conversation: web pages render in a fully
sandboxed iframe (no scripts, opaque origin), PDFs in the browser viewer,
images inline, CSV/TSV/JSON as a sortable table, text/markdown rendered. A
previewed web page loads none of its remote images or styles by default, so
opening it never reveals the viewer to the page's site; a toggle in the pane
header turns remote content back on for that preview. The
model receives only a one-line confirmation — to reason about content, use
`web_fetch` / `read_file` instead. Preview content is size-capped per kind
(pages 4 MB, PDFs 32 MB, images 4 MB, tables 2 MB, text 512 KB) and GC'd
with the workstream.
- **Auto-approve**: URL targets require confirmation (network access); file
paths and `attachment:` targets run unprompted (local reads).
- **Agent availability**: interactive sessions only (not `task_agent`, not
coordinators).
- **Surfaces**: the pane renders in the web UI (standalone and console). The
CLI prints the confirmation line only — there is no terminal pane.
---
## Agent
The tool name uses the `_agent` suffix — bare `task` collides with
@@ -545,6 +588,7 @@ pre-configure skills at workstream creation.
| `search` | File Ops | Yes | Yes | `query` |
| `web_fetch` | Info | No | Yes | `url` |
| `web_search` | Info | No | Yes | `query` |
| `open_preview`| Info | URL: no; path/attachment: yes | No | `target` |
| `task_agent` | Agent | No | No | `prompt` |
| `memory` | Memory | Yes | No | `name` |
| `recall` | Memory | Yes | No | `query` |
@@ -580,6 +624,11 @@ Tool search uses the best available mechanism for each provider:
`_exec_tool_search()` runs a pure-Python BM25 index over tool names and
descriptions, then expands the matched tools into the visible set.
A persona with a tool-visibility set overrides this selection: any exact
set forces tool search into the client-side BM25 mechanism (tier 3)
regardless of provider, and a **hard** set — one whose visible tools omit
`tool_search` — disables tool search entirely.
### Configuration
Tool search is configured in `config.toml` under the `[tools]` section:
@@ -649,7 +698,7 @@ MCP-compatible service.
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
4. **Merging**: MCP tools are appended after the 16 built-in tools via
4. **Merging**: MCP tools are appended after the 17 built-in tools via
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
When dynamic tool search is active, MCP tools are deferred rather than directly
visible -- the model discovers them via search as needed (see
@@ -736,7 +785,10 @@ MCP tool lists stay up-to-date without restart through two mechanisms:
1. **Push notifications** -- MCP servers that declare `tools.listChanged: true` in
their capabilities send `notifications/tools/list_changed` when their tool list
changes. `MCPClientManager` registers a `message_handler` on each `ClientSession`
that triggers an immediate refresh for that server.
that triggers an immediate refresh for that server (debounced per server and
notification kind, and run off the receive loop). A refresh that fails while
the connection stays up is retried automatically on the next health-loop tick
until one completes.
2. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
`/mcp refresh <server>` targets a single server. If a server has disconnected,
@@ -744,6 +796,10 @@ MCP tool lists stay up-to-date without restart through two mechanisms:
same controls (refresh / reconnect buttons per server) for cluster-wide
fan-out.
Reconnects (health-loop, dispatch-driven, or operator-forced) always end in a
full catalog rediscovery, so a server that changed its tools while disconnected
comes back current.
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
instances via registered listener callbacks. Each session rebuilds its `_tools`,
@@ -814,13 +870,16 @@ catalog.
### Refresh
Resource lists stay current through the same three-tier mechanism as tool lists:
Resource lists stay current through the same mechanisms as tool lists:
1. **Push** -- Servers declaring `resources.listChanged: true` send
`notifications/resources/list_changed`, triggering an immediate refresh.
2. **Periodic** -- Servers without push are polled on the configured refresh
interval (default 4 hours, same timer as tools).
3. **Manual** -- `/mcp refresh` re-fetches resources alongside tools.
`notifications/resources/list_changed`, triggering an immediate refresh
(with the same failed-refresh retry on the health-loop tick).
2. **Manual** -- `/mcp refresh` re-fetches resources alongside tools.
Servers without push support are refreshed whenever they reconnect (every
reconnect ends in full rediscovery) or when an operator refreshes manually;
there is no periodic polling.
---
+56
View File
@@ -0,0 +1,56 @@
{
"defaults": {
"n_runs": 3
},
"cases": [
{
"id": "search-first",
"skill": {
"name": "search-first",
"content": "# Search First\n\nBefore answering ANY question about where something lives in the codebase, you MUST call the `search` tool first. Never answer from memory."
},
"user_prompt": "Where is JWT token validation implemented in this project?",
"expected_actions": [{ "tool": "search" }],
"match_mode": "ordered_subset",
"max_turns": 4
},
{
"id": "test-after-edit",
"skill": {
"name": "test-after-edit",
"content": "# Test After Edit\n\nAfter editing or writing ANY file, you MUST run the test suite with `python -m pytest` via bash before you finish. Do not report done until tests have run."
},
"user_prompt": "Add a function `clamp(x, lo, hi)` that clamps x to [lo, hi] in utils.py.",
"setup": {
"files": {
"utils.py": ""
}
},
"expected_actions": [
{ "tool": "write_file" },
{ "tool": "bash", "args_pattern": { "command": "pytest" } }
],
"match_mode": "ordered_subset",
"max_turns": 8
},
{
"id": "changelog-update",
"skill": {
"name": "changelog-update",
"content": "# Changelog Discipline\n\nWhenever you modify a file, you MUST also append a one-line entry to CHANGELOG.md describing the change in the same task."
},
"user_prompt": "Fix the off-by-one so pager.py shows the last page. Edit pager.py.",
"setup": {
"files": {
"pager.py": "def last_page(total_items, per_page):\n # off-by-one: drops the final partial page\n return total_items // per_page\n",
"CHANGELOG.md": "# Changelog\n"
}
},
"expected_actions": [
{ "tool": "edit_file", "args_pattern": { "path": "CHANGELOG.md" } }
],
"match_mode": "subset",
"max_turns": 8
}
]
}
+9 -7
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.7.0a6"
version = "1.8.0a6"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "Apache-2.0"
@@ -23,8 +23,8 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = [
"openai>=2.37",
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
"openai>=2.45", # GPT-5.6: typed reasoning.mode, prompt_cache_options, and cache_write_tokens
"anthropic>=0.117", # tracks the release current at claude-opus-5 onboarding; hard runtime floor is still 0.105 (mid-conversation system blocks) — Opus 5 itself needs no new SDK surface (model ids are opaque strings; "refusal" has been in the StopReason literal since ~0.95). Raise this when adopting fast mode / server-side fallbacks / advisor / mid-conversation tool changes, which DO need newer typed params.
"httpx>=0.28",
"mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately
"starlette>=1.3.1", # CVE-2026-54282 (path->authority host spoof) + CVE-2026-54283 (url-encoded form DoS); supersedes the PYSEC-2026-161 host-header path-injection floor
@@ -64,7 +64,8 @@ all = ["turnstone[discord,slack]"]
[project.scripts]
turnstone = "turnstone.cli:main"
turnstone-eval = "turnstone.eval:main"
turnstone-eval = "turnstone.eval.cli:main"
turnstone-optimizer = "turnstone.optimizer:main"
turnstone-server = "turnstone.server:main"
turnstone-console = "turnstone.console.server:main"
turnstone-admin = "turnstone.admin:main"
@@ -87,10 +88,10 @@ include = [
"turnstone/console/static/coordinator/*.js",
"turnstone/shared_static/*.css",
"turnstone/shared_static/*.js",
"turnstone/shared_static/katex-0.17.0/**/*",
"turnstone/shared_static/katex-0.18.1/**/*",
"turnstone/shared_static/hljs-11.11.1/**/*",
"turnstone/shared_static/mermaid-11.16.0/**/*",
"turnstone/shared_static/hls-1.6.16/**/*",
"turnstone/shared_static/mermaid-11.16.1/**/*",
"turnstone/shared_static/hls-1.6.17/**/*",
"turnstone/sdk/py.typed",
"turnstone/deploy/*.yaml",
"turnstone/deploy/Caddyfile",
@@ -102,6 +103,7 @@ testpaths = ["tests"]
markers = [
"live: requires a running LLM backend",
"allow_thread_leak: test intentionally leaves a background thread running (opts out of the leaked-thread guard)",
"e2e_recovery: opt-in end-to-end SSE recovery harness (real server + real SSE consumers, scripted provider — NOT live, no LLM backend needed); tens of seconds each. CI lanes run ``-m 'not live and not e2e_recovery'``; select with ``-m e2e_recovery``.",
]
filterwarnings = [
# mcp v1 deprecates streamablehttp_client for an entry point whose call
+89 -7
View File
@@ -4,8 +4,9 @@
#
# curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
#
# Autodetects your distro (Ubuntu/Debian, Fedora/RHEL, Arch, and WSL on any of
# them) and:
# Autodetects your distro Ubuntu/Debian, Fedora/RHEL, Arch, their common
# derivatives (Mint, Pop!_OS, Nobara, AlmaLinux, …), and WSL on any of them —
# and:
# 1. ensures git is installed, then clones the repo
# 2. ensures Docker + the compose plugin are installed and the daemon is usable
# 3. asks how many server nodes to run (1-10)
@@ -65,12 +66,18 @@ ask() {
# -- distro / package manager detection --------------------------------------
OS_ID=""; OS_LIKE=""; PKG=""; IS_WSL=0; SUDO=""
# Extra os-release fields, captured only to pick Docker's upstream repo when
# get.docker.com refuses a derivative it doesn't recognize (see install_docker).
OS_PLATFORM_ID=""; OS_CODENAME=""; OS_UBUNTU_CODENAME=""
detect_os() {
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"
OS_PLATFORM_ID="${PLATFORM_ID:-}"
OS_CODENAME="${VERSION_CODENAME:-}"
OS_UBUNTU_CODENAME="${UBUNTU_CODENAME:-}"
fi
if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null || [ -n "${WSL_DISTRO_NAME:-}" ]; then
IS_WSL=1
@@ -130,11 +137,83 @@ clone_repo() {
# -- docker -------------------------------------------------------------------
DOCKER="docker"
# Fallback when get.docker.com won't install here. That script keys off $ID alone
# (never ID_LIKE), so it aborts with "Unsupported distribution '<id>'" on every
# derivative — Nobara, Linux Mint, Pop!_OS, AlmaLinux, Oracle Linux, … — even
# though the family is clear. We already know the family from detect_os, so we add
# Docker's official CE repo for the matching upstream and install the same
# packages get.docker.com would (including the compose plugin the rest of run.sh
# relies on).
install_docker_ce_repo() {
local up
case "$PKG" in
apt)
local codename arch
# UBUNTU_CODENAME is set by Ubuntu and every Ubuntu-derived distro
# (Mint/Pop!_OS/Zorin/…) and never by pure Debian, so it both routes
# the family and gives the exact codename Docker's repo expects.
if [ -n "$OS_UBUNTU_CODENAME" ]; then
up=ubuntu; codename="$OS_UBUNTU_CODENAME"
else
up=debian; codename="$OS_CODENAME"
fi
[ -n "$codename" ] || die "couldn't determine the $up release codename for Docker's repo — install Docker manually and re-run."
arch="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
info "Adding Docker's $up repository ($codename)."
$SUDO install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/$up/gpg" | $SUDO tee /etc/apt/keyrings/docker.asc >/dev/null
$SUDO chmod a+r /etc/apt/keyrings/docker.asc
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
"$arch" "$up" "$codename" | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
$SUDO apt-get update -y
$SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
;;
dnf|yum)
# A Fedora spin and a RHEL clone can both carry "fedora" in ID_LIKE
# (Nobara's is "rhel centos fedora"), so ID_LIKE can't separate them.
# PLATFORM_ID can: Fedora is platform:fNN, Enterprise Linux platform:elN.
case "$OS_PLATFORM_ID" in
platform:f*) up=fedora ;;
platform:el*) up=centos ;;
*) if [ -e /etc/fedora-release ]; then up=fedora; else up=centos; fi ;;
esac
info "Adding Docker's $up repository."
$SUDO curl -fsSL "https://download.docker.com/linux/$up/docker-ce.repo" \
-o /etc/yum.repos.d/docker-ce.repo \
|| die "couldn't add Docker's $up repository — install Docker manually and re-run."
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
;;
esac
}
# The distro IDs get.docker.com installs directly: it matches $ID against this
# exact set (ignoring ID_LIKE) and aborts on anything else. Mirrors the dispatch
# in get.docker.com, including its fedora-asahi-remix -> fedora alias.
get_docker_com_supports() {
case "$1" in
ubuntu|debian|raspbian|centos|fedora|rhel|rocky|sles|fedora-asahi-remix) return 0 ;;
*) return 1 ;;
esac
}
install_docker() {
case "$PKG" in
apt|dnf|yum)
info "Installing Docker via the official get.docker.com script"
curl -fsSL https://get.docker.com | $SUDO sh ;;
# Decide up front which installer applies, rather than treating every
# get.docker.com failure as "unsupported distro": for an ID it knows,
# let it run and surface any real failure (network, apt lock, EOL) via
# die instead of masking it with the repo path. Only unrecognized
# derivatives (Nobara, Mint, …) — which it would just abort on — skip
# straight to adding Docker's repo ourselves.
if [ -n "$OS_ID" ] && ! get_docker_com_supports "$OS_ID"; then
info "get.docker.com doesn't support '$OS_ID' — using Docker's official repository directly."
install_docker_ce_repo
else
info "Installing Docker via the official get.docker.com script"
curl -fsSL https://get.docker.com | $SUDO sh \
|| die "get.docker.com failed to install Docker (see the output above). Fix the issue and re-run — the script resumes."
fi
;;
pacman)
pkg_install docker docker-compose ;;
esac
@@ -366,12 +445,15 @@ ${GREEN}${BOLD}Turnstone is running${RESET} (${NODE_COUNT} node$([ "$NODE_COUNT"
${DIM}cd $INSTALL_DIR && $DOCKER compose exec caddy cat /data/caddy/pki/authorities/local/root.crt${RESET}
Finish setup
1. Create the first admin user:
${DIM}cd $INSTALL_DIR && $DOCKER compose exec node-1 turnstone-admin create-user --username admin --name "Admin"${RESET}
2. Open ${url}, log in, and add a model backend in the ${BOLD}Models${RESET} tab —
1. Open ${BOLD}${url}${RESET} and create the admin account when prompted —
the first user created there gets full admin access.
2. Log in, then add a model backend in the ${BOLD}Models${RESET} tab —
a local server (vLLM / llama.cpp) or an OpenAI / Anthropic / Gemini key.
Nodes boot without a model and pick it up live; no restart needed.
${DIM}No browser? Create the admin from the CLI instead:
cd $INSTALL_DIR && $DOCKER compose exec node-1 turnstone-admin create-admin --username admin --name "Admin"${RESET}
Scale Running ${scale}
Manage ${DIM}cd $INSTALL_DIR${RESET}
+523 -44
View File
@@ -45,6 +45,22 @@ Shell harness (?split=): right (default) · down · three · none — boots the
document.title stamps SPLIT-READY-<visible cells> on success and
SPLIT-FAILED-<reason> when a driven split was denied judge the focused
cell's top accent bar, the separators, and the .shown tab marker.
Proxy-brand harness (/proxybrand/livepass.html): back-to-console from a
PROXIED node view, driven end to end. An iframe hosts a node page built
from the REAL shell.js rail plus the REAL _JS_PROXY_SHIM (read out of
turnstone/console/server.py by text, never imported -- scripts/ has no
sys.path guard, so an import would silently pick up site-packages). The
host clicks the brand's child span and, because the shim navigates the
FRAME away, reads the frame's post-navigation location from the surviving
top page. document.title stamps PROXYBRAND-READY, or
PROXYBRAND-FAILED-<reason>: sub-not-repointed-server (nothing wired),
showhome-also-ran (shell.js won the click), nav-<path> (went somewhere
other than the console root), sub-not-console-<text>, aria-not-repointed,
no-navigation, no-brand, no-sub. Needs --virtual-time-budget=9000;
there is nothing to screenshot. Read the verdict from <title> --
both literals also appear in the host page's inline script, so a bare
grep over --dump-dom output false-positives.
Attachments harness (/attachments/livepass.html): the composer attachment
chips + the sent-message attachment pills, both driven through the REAL
code paths createAttachmentController.rehydrate() builds the chips and
@@ -79,6 +95,30 @@ Task-agent harness (/taskagent/livepass.html): the task_agent card — a task
success, TASKAGENT-FAILED-... / TASKAGENT-ERROR when routing breaks, so a
broken card can't screenshot green.
Copy harness (/copy/livepass.html): the copy-to-clipboard affordances the
per-bubble copy button in .msg-actions and the floating block-copy button
over hovered fences / mermaid diagrams / tables (pointer-only; keyboard
copies with Enter on the focused block) driven through the REAL
InteractivePane (replayHistory plus a live handleEvent stream turn, so the
retry-holder buttons coexist with the persistent copy buttons on the last
bubble; the turn ends idle, matching the affordances' idle-only gate).
navigator.clipboard is stubbed to a recorder, hover/focus/keys are
dispatched synthetically, and every copied payload is compared byte-exact
against the SOURCE (fences, pipes, mermaid text, the bubble's raw
markdown). + &theme=light. document.title stamps
COPY-READY-<bubbles>-<blocks> only when every probe copied exact source;
COPY-FAILED-<reason> otherwise. &kbd=1 probes the KEYBOARD path: focus a
block, dispatch Enter the block's source lands on the clipboard, the
block carries the outcome flash class, and the floating button stays out
of it stamps COPY-KBD-READY / COPY-KBD-FAILED-<step>.
Screenshot states: &flash=1 (visual-only
run no probes; floating button + state on the fence, holder bar
revealed via focus) and &bare=1 (single hover, no decoration). Known
capture artifact: the DARK-theme &flash=1 shot can omit the floating
button's pixels (headless software compositor; the DOM state is correct
and light theme paints) judge the dark floating button from &bare=1
and the state from the light shot. &stepmax=N bisects a paint
regression to the interaction that triggers it.
Perf harness (/perf/livepass.html): long-session performance baseline for the
interactive pane mounts the REAL InteractivePane at real scroll geometry
(fixed-height mount, production CSS chain) and drives production-shaped
@@ -143,6 +183,24 @@ def extract_admin_fragment() -> str:
return html[start:end]
def extract_proxy_shim(prefix: str = "/node/livepass-node") -> str:
"""Pull ``_JS_PROXY_SHIM`` out of console/server.py BY TEXT, not import.
``scripts/`` has no ``sys.path`` guard, so ``import turnstone`` from here
resolves to whatever is installed in site-packages rather than this
checkout -- silently building the page from a DIFFERENT version of the
shim than the one you are trying to verify. Read the source instead.
"""
src = (ROOT / "turnstone/console/server.py").read_text(encoding="utf-8")
m = re.search(r'^_JS_PROXY_SHIM = """\\\n(.*?)^"""', src, re.S | re.M)
if not m:
raise SystemExit(
"livepass: could not find _JS_PROXY_SHIM in turnstone/console/server.py "
"-- the constant was renamed or reshaped; update extract_proxy_shim()."
)
return m.group(1).replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
def inject(template: str, marker: str, payload: str) -> str:
begin = template.index(f"<!-- {marker}:BEGIN -->") + len(f"<!-- {marker}:BEGIN -->")
end = template.index(f"<!-- {marker}:END -->")
@@ -347,6 +405,28 @@ CONSOLE_TEMPLATE = """<!doctype html>
<div id="toast" role="status" aria-live="polite"></div>
<script>
(function () {
// Freeze window.fetch BEFORE the module scripts evaluate: auth.js
// fires a boot-time whoami at import, and a non-OK answer from the
// fixture server would CLEAR the permissions grant seeded below
// mid-pass. A never-settling fetch keeps the seed authoritative;
// everything the passes drive flows through the authFetch fixture
// (reinstated after auth.js's window bridge runs — see the load
// handler).
window.fetch = function () {
return new Promise(function () {});
};
// Grant the operator scopes admin.js gates on: _modelAuthEditable()
// reads this exact key THROUGH the real auth.js hasPermission
// (loaded below, before admin.js) without the grant, or without
// auth.js supplying window.hasPermission, the auth-constraints
// stub below is dead code: _fetchModelAuthConstraints returns
// before authFetch and every pass renders the Backend-auth section
// in its read-only degraded state. The headless profile is fresh
// per pass, so nothing else seeds it.
sessionStorage.setItem(
"turnstone_permissions",
"admin.models,admin.mcp",
);
function reply(data) {
return Promise.resolve({
ok: true,
@@ -372,9 +452,15 @@ CONSOLE_TEMPLATE = """<!doctype html>
enabled: true, temperature: null, max_tokens: null,
reasoning_effort: null, surface_persisted_reasoning: true,
replay_reasoning_to_model: false,
auth_mode: "static", obo_audience: "", obo_scopes: "",
};
window.__putCount = 0;
window.authFetch = function (url, opts) {
// Held under a private name too: auth.js's legacy window bridge
// (Object.assign(window, {authFetch})) runs at module-import time
// and clobbers the plain window.authFetch assigned here the load
// handler reinstates the fixture from this name after the modules
// have evaluated.
window.__consoleAuthFetch = window.authFetch = function (url, opts) {
var method = (opts && opts.method) || "GET";
if (method === "PUT" && url.indexOf("/model-definitions/def1") >= 0) {
window.__putCount++;
@@ -399,13 +485,31 @@ CONSOLE_TEMPLATE = """<!doctype html>
known: true,
capabilities: {
context_window: 200000, supports_tools: true,
supports_streaming: true, supports_vision: true,
supports_vision: true,
supports_web_search: true, supports_temperature: true,
supports_effort: true,
},
});
if (url.indexOf("/model-definitions/auth-constraints") >= 0)
// Fetched by the shelf ON OPEN (showCreateModelModal /
// showEditModelModal), so this stub is exercised by any pass that
// opens the model editor no tab-switch plumbing needed. Omitting
// it would render the Backend-auth block in its degraded
// no-suggestions state and quietly stop exercising the section.
return reply({
auth_audience_allowlist: ["api://example-gateway"],
auth_grant_profile: "entra",
dynamic_auth_modes: ["entra_app", "entra_obo", "rfc8693_obo"],
scopes_auth_modes: ["rfc8693_obo"],
app_identity_auth_modes: ["entra_app"],
auth_mode_profiles: {
entra_app: "entra", entra_obo: "entra",
rfc8693_obo: "rfc8693",
},
});
if (url.indexOf("/model-definitions/def1") >= 0) return reply(MODEL);
if (url.indexOf("/model-definitions") >= 0) return reply({ models: [] });
if (url.indexOf("/model-definitions") >= 0)
return reply({ models: [], default_alias: "fable-5" });
if (url.indexOf("/api/models") >= 0)
return reply({ models: [
{ alias: "fable-5", model: "claude-fable-5" },
@@ -432,10 +536,22 @@ CONSOLE_TEMPLATE = """<!doctype html>
</script>
<script type="module" src="shared/utils.js"></script>
<script type="module" src="shared/hatch.js"></script>
<!-- The REAL auth.js, loaded (and therefore parsed) before admin.js's
permission shims run any pass: it owns the sessionStorage parse
contract and assigns the window.hasPermission /
window.whenPermissionsReady globals the shims probe at call time.
Without it the seeded permissions grant is never READ, the
Backend-auth section renders read-only/hidden, and the
auth-constraints stub above is dead code in every pass. -->
<script type="module" src="shared/auth.js"></script>
<script src="console-static/admin.js"></script>
<script src="console-static/governance.js"></script>
<script>
window.addEventListener("load", function () {
// Reinstate the fixture fetch now the modules (and auth.js's
// window bridge) have evaluated passes run after load, so every
// shelf-open fetch flows through the fixture, not the bridge.
window.authFetch = window.__consoleAuthFetch;
var q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
@@ -658,6 +774,108 @@ SHELL_TEMPLATE = """<!doctype html>
# call the same window.buildAttachmentPreview). The page frame is harness-only
# chrome and not under review; the chips row and the pill row are.
# --------------------------------------------------------------------------
# The PROXIED NODE page: the real L-shell (so the rail brand is the real
# element, with the real shell.js click listener on it) plus the real proxy
# shim injected exactly where proxy_index puts it -- first thing inside
# <body>, ahead of the deferred shell.js module. caps mirror a NODE, not
# the console: brandSub "server" is what the shim has to overwrite, and
# leaving it "console" would make the host's /console/i check vacuous.
PROXYBRAND_FRAME_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>proxied node</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="static/style.css" />
<link rel="stylesheet" href="shared/shell.css" />
</head>
<body>
<!-- SHIM:BEGIN -->
<!-- SHIM:END -->
<div id="header" style="display: none"><div id="status-bar"></div></div>
<div id="main" style="padding: 18px">
<h2 style="margin: 0 0 8px">Node dashboard</h2>
</div>
<div id="view-admin" style="display: none"></div>
<script>
window.TURNSTONE_SHELL_CAPS = { cluster: false, brandSub: "server" };
window.TS_APP = {
boot() {},
getClusterState() { return { nodes: {} }; },
onRender() {},
};
window.TS_ADMIN = {};
// Record on the PARENT, which survives the frame's navigation.
// A flag on the frame's own window dies with the document, so the
// host would read undefined and pass -- a check that cannot fail.
window.showHome = function () {
try { window.parent.__showHomeRan = true; } catch (e) {}
};
</script>
<script type="module" src="shared/shell.js"></script>
</body>
</html>
"""
# The HOST page. The shim navigates the FRAME to "/", which would destroy
# any verdict stamped inside it -- so the surviving top page reads the
# frame's post-navigation location and stamps its own title instead. No
# landing page at "/" is needed (the harness root serves a directory
# listing) and no CDP client either.
PROXYBRAND_HOST_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>proxybrand livepass</title>
<style>
html, body { margin: 0; height: 100%; }
iframe { width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<iframe id="frame" src="frame.html"></iframe>
<script>
const frame = document.getElementById("frame");
let phase = 0;
const fail = (r) => { phase = 9; document.title = "PROXYBRAND-FAILED-" + r; };
frame.addEventListener("load", () => {
if (phase === 9) return;
if (phase === 0) {
const doc = frame.contentDocument;
const brand = doc.querySelector(".rail-brand .brand-home");
if (!brand) return fail("no-brand");
const sub = brand.querySelector(".brand-sub");
if (!sub) return fail("no-sub");
const text = sub.textContent.trim();
if (text === "server") return fail("sub-not-repointed-server");
if (!/console/i.test(text)) return fail("sub-not-console-" + text);
if (brand.getAttribute("aria-label") !== "Back to console")
return fail("aria-not-repointed");
phase = 1;
// Click the CHILD span, as a real user does: the shim must match
// via contains(), not target identity.
sub.click();
setTimeout(() => { if (phase === 1) fail("no-navigation"); }, 2000);
return;
}
const path = frame.contentWindow.location.pathname;
const ranShowHome = !!window.__showHomeRan;
phase = 2;
if (path !== "/") return fail("nav-" + path);
if (ranShowHome) return fail("showhome-also-ran");
// Sticky, mirroring fail(): a third load must not re-stamp.
phase = 9;
document.title = "PROXYBRAND-READY";
});
</script>
</body>
</html>
"""
ATTACH_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
@@ -821,7 +1039,24 @@ ATTACH_TEMPLATE = """<!doctype html>
# is exercised, not just the leaf builders. The page frame is harness-only
# chrome; the .conv-batch / task_agent card is what's under review.
# --------------------------------------------------------------------------
TASKAGENT_TEMPLATE = """<!doctype html>
# The host seams a mounted InteractivePane provides, stubbed once for every
# harness that drives the REAL pane (taskagent, copy). A new required seam
# gets added HERE — a harness left with a stale stub set does not fail at
# review time, it throws HARNESS ERROR at run time.
PANE_STUB_JS = """\
// Drive the REAL pane; stub only the host seams a mounted pane provides.
const pane = new InteractivePane("demo-ws");
pane.messagesEl = messages;
pane.inputEl = document.createElement("textarea");
pane.sendBtn = document.createElement("button");
pane.isNearBottom = () => false;
pane.scrollToBottom = () => {};
pane.removeEmptyState = () => {};
pane.removeThinkingIndicator = () => {};
pane.setBusy = () => {};"""
TASKAGENT_TEMPLATE = (
"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@@ -871,16 +1106,9 @@ TASKAGENT_TEMPLATE = """<!doctype html>
const messages = document.getElementById("messages");
try {
// Drive the REAL pane; stub only the host seams a mounted pane provides.
const pane = new InteractivePane("demo-ws");
pane.messagesEl = messages;
pane.inputEl = document.createElement("textarea");
pane.sendBtn = document.createElement("button");
pane.isNearBottom = () => false;
pane.scrollToBottom = () => {};
pane.removeEmptyState = () => {};
pane.removeThinkingIndicator = () => {};
pane.setBusy = () => {};
"""
+ PANE_STUB_JS
+ """
const ev = (e) => pane.handleEvent(e);
// ?recall=1: exercise the RECALL path replayHistory rebuilding the
@@ -1029,6 +1257,266 @@ TASKAGENT_TEMPLATE = """<!doctype html>
</body>
</html>
"""
)
# --------------------------------------------------------------------------
# Copy harness — the copy-to-clipboard affordances over the REAL pane. The
# bubbles come from the REAL replayHistory / handleEvent paths so the copy
# sources are the ones production stashes (_copySource, the mermaid / table
# data attributes), and the probes drive the REAL buttons and key path and
# compare what landed on the (stubbed) clipboard byte-exact against the
# source.
# --------------------------------------------------------------------------
COPY_TEMPLATE = (
"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>copy livepass</title>
<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/conversation.css" />
<link rel="stylesheet" href="shared/cards.css" />
<link rel="stylesheet" href="shared/interactive.css" />
<style>
/* Harness-only framing (NOT under review) a plausible pane context. */
body {
padding: 24px; margin: 0; background: var(--bg); color: var(--ink);
font-family: var(--font-sans, system-ui, sans-serif);
}
.demo-frame { max-width: 720px; margin: 0 auto; }
.demo-label {
font: 11px var(--font-mono, monospace); color: var(--ink-3);
text-transform: uppercase; letter-spacing: 0.08em; margin: 0 0 8px;
}
</style>
</head>
<body>
<div class="demo-frame">
<div class="demo-label">conversation copy affordances (real InteractivePane)</div>
<div class="messages" id="messages"></div>
</div>
<script>
window.toast = { error: function (m) { console.log("toast:", m); } };
window.authFetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve({}); },
text: function () { return Promise.resolve(""); },
});
};
// Deterministic clipboard: record instead of writing. localhost is a
// secure context so copyTextToClipboard takes the async-API branch and
// hits this stub; force isSecureContext for any odd serving setup.
window.__copied = [];
try {
Object.defineProperty(window, "isSecureContext", { value: true });
} catch (e) { /* already true */ }
try {
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: function (t) {
window.__copied.push(t);
return Promise.resolve();
},
},
configurable: true,
});
} catch (e) {
document.title = "COPY-FAILED-clipboard-stub";
}
</script>
<script type="module">
import { InteractivePane } from "./shared/interactive.js";
const q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
const FENCE_SRC = 'def stash(depth):\\n total = 0\\n for k in range(depth):\\n total += k\\n return total';
const TABLE_SRC = '| node | state |\\n|---|:--:|\\n| flat | idle |\\n| blck | busy |';
const MERMAID_SRC = 'graph TD\\n A --> B\\n B --> C';
const MD_ONE =
'First reply with a fence and a table.\\n\\n' +
'```python\\n' + FENCE_SRC + '\\n```\\n\\n' +
TABLE_SRC + '\\n\\nTrailing prose under the table.';
const MD_TWO =
'Second reply with a diagram.\\n\\n' +
'```mermaid\\n' + MERMAID_SRC + '\\n```\\n\\n' +
'And `inline code` after it.';
const MD_LIVE =
'Streamed reply: the **live** turn, so the retry holder lands here.';
const messages = document.getElementById("messages");
const fail = (r) => { document.title = "COPY-FAILED-" + r; };
try {
"""
+ PANE_STUB_JS
+ """
pane.replayHistory([
{ role: "user", content: "Show me the stash helper and the node table." },
{ role: "assistant", content: MD_ONE },
{ role: "user", content: "Now the flow as a diagram, please." },
{ role: "assistant", content: MD_TWO },
]);
// A live streamed turn on top the retry holder must land on this
// bubble WITHOUT stripping its (or any) copy button.
pane.handleEvent({ type: "state_change", state: "running" });
for (let k = 0; k < MD_LIVE.length; k += 16)
pane.handleEvent({ type: "content", text: MD_LIVE.slice(k, k + 16) });
pane.handleEvent({ type: "stream_end" });
pane.handleEvent({ type: "state_change", state: "idle" });
const hover = (el) =>
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
const fabEl = () => document.querySelector(".block-copy-btn");
// Let the streamed bubble's rAF render + retry attach settle.
setTimeout(async () => {
try {
const bubbles = messages.querySelectorAll(".msg.assistant");
const bars = messages.querySelectorAll(
".msg.assistant .msg-actions .msg-copy-btn",
);
if (bubbles.length !== 3) return fail("bubbles" + bubbles.length);
if (bars.length !== 3) return fail("bars" + bars.length);
const last = bubbles[bubbles.length - 1];
if (!last.querySelector(".msg-retry-btn"))
return fail("no-retry-on-holder");
if (!last.querySelector(".msg-copy-btn"))
return fail("holder-lost-copy");
// Block probes: hover reveals the floating button; a click must
// land the byte-exact SOURCE on the clipboard.
const probes = [
[messages.querySelector(".msg.assistant pre"), FENCE_SRC, "fence"],
[messages.querySelector(".table-wrap"), TABLE_SRC, "table"],
[messages.querySelector(".mermaid-container"), MERMAID_SRC, "mermaid"],
];
// &bare=1 diagnostic state: no probe clicks, no repositioning;
// one hover on the fence and stop. Splits "the probe cycle
// corrupts the button's paint" from "it never paints here".
if (q.get("bare") === "1") {
hover(probes[0][0]);
document.title = "COPY-BARE";
return;
}
// &kbd=1 the keyboard path: Enter on a FOCUSED block copies
// that block's source directly. Blocks are focusable (tabindex=0
// from the fence / table / mermaid renders), the outcome flashes
// on the block itself, and the floating button pointer-only
// must stay out of it entirely (never created, never revealed).
if (q.get("kbd") === "1") {
const tw = messages.querySelector(".table-wrap");
if (!tw) return fail("kbd-no-block");
tw.focus();
const focused = document.activeElement === tw;
tw.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
await new Promise((r) => setTimeout(r, 0));
const copied =
window.__copied[window.__copied.length - 1] === TABLE_SRC;
const flashed = tw.classList.contains("is-copied");
const fabStaysOut =
!fabEl() || !fabEl().classList.contains("is-visible");
document.title =
focused && copied && flashed && fabStaysOut
? "COPY-KBD-READY"
: "COPY-KBD-FAILED-" +
[
focused ? "" : "focus",
copied ? "" : "copy",
flashed ? "" : "flash",
fabStaysOut ? "" : "fab",
]
.filter(Boolean)
.join("-");
return;
}
// &flash=1 the VISUAL state, screenshot-only: skip the probes so
// the fence hover is the floating button's FIRST show. Returning
// the button to an already-visited position stops it PAINTING in
// headless captures (visible + hit-testable, no pixels a stale
// compositor tile; bisected via &stepmax). Function and pixels
// are therefore split: the probe run (no flash) is the verdict,
// this state is the picture.
if (q.get("flash") === "1") {
bars[bars.length - 1].focus();
hover(probes[0][0]);
const fab = fabEl();
if (!fab) return fail("no-fab-visual");
fab.classList.add("is-copied");
fab.title = "Copied";
// Freeze: the capture pipeline synthesizes a pointer event
// outside the block at screenshot time, which would hide the
// button (correct in production). Capture-phase stops starve
// the module's delegated listeners for the capture.
for (const t of ["mouseover", "scroll"])
document.addEventListener(t, (e) => e.stopPropagation(), true);
document.title = "COPY-VISUAL";
return;
}
// &stepmax=N diagnostic: stop after the Nth interaction (hovers
// and clicks count) and stamp COPY-STEP-N, so a paint regression
// can be bisected to the interaction that triggers it.
let step = 0;
const stepMax = parseInt(q.get("stepmax") || "999", 10);
const gate = () => {
step += 1;
if (step > stepMax) {
document.title = "COPY-STEP-" + (step - 1);
throw { __stop: true };
}
};
let done = 0;
for (const [el, want, name] of probes) {
if (!el) return fail("no-" + name);
gate();
hover(el);
const fab = fabEl();
if (!fab || !fab.classList.contains("is-visible"))
return fail("fab-hidden-" + name);
gate();
fab.click();
await new Promise((r) => setTimeout(r, 0));
const got = window.__copied[window.__copied.length - 1];
if (got !== want) {
console.log("copy mismatch", name, JSON.stringify(got));
return fail("source-" + name);
}
done += 1;
}
// Bubble probe: the whole raw markdown, fences and pipes intact.
gate();
bars[0].click();
await new Promise((r) => setTimeout(r, 0));
if (window.__copied[window.__copied.length - 1] !== MD_ONE)
return fail("bubble-source");
document.title = "COPY-READY-" + bars.length + "-" + done;
} catch (e) {
if (!(e && e.__stop)) {
console.log("copy harness error", e);
fail("error");
}
}
}, 400);
} catch (e) {
messages.textContent = "HARNESS ERROR: " + e.message;
fail("error");
}
</script>
</body>
</html>
"""
)
# --------------------------------------------------------------------------
@@ -1267,12 +1755,6 @@ PERF_TEMPLATE = """<!doctype html>
let phase = "mount";
try {
const pane = new InteractivePane("perf-ws");
// ?window= overrides the pane's transcript window (message count),
// e.g. ?window=100000 disables windowing to isolate the
// content-visibility/block-flow effect from the windowing effect.
// Default (0) measures shipped behavior.
const WINDOW = parseInt(q.get("window") || "0", 10);
if (WINDOW > 0) pane._historyWindow = WINDOW;
document.getElementById("mount").appendChild(pane.el);
const msgs = buildHistory(N);
report.heap_start = heapBytes();
@@ -1482,6 +1964,12 @@ def build(out: Path) -> None:
(ta / "livepass.html").write_text(TASKAGENT_TEMPLATE, encoding="utf-8")
print(f"{ta}/livepass.html — task_agent card (real Pane.handleEvent routing)")
cp = out / "copy"
cp.mkdir(parents=True, exist_ok=True)
symlink(cp / "shared", ROOT / "turnstone/shared_static")
(cp / "livepass.html").write_text(COPY_TEMPLATE, encoding="utf-8")
print(f"{cp}/livepass.html — copy affordances (bubble bars + block button)")
pf = out / "perf"
pf.mkdir(parents=True, exist_ok=True)
symlink(pf / "shared", ROOT / "turnstone/shared_static")
@@ -1489,6 +1977,17 @@ def build(out: Path) -> None:
(pf / "livepass.html").write_text(PERF_TEMPLATE, encoding="utf-8")
print(f"{pf}/livepass.html — long-session perf baseline (real InteractivePane)")
pb = out / "proxybrand"
pb.mkdir(parents=True, exist_ok=True)
symlink(pb / "shared", ROOT / "turnstone/shared_static")
symlink(pb / "static", ROOT / "turnstone/ui/static")
shim = "<script>" + extract_proxy_shim() + "</script>"
(pb / "frame.html").write_text(
inject(PROXYBRAND_FRAME_TEMPLATE, "SHIM", shim), encoding="utf-8"
)
(pb / "livepass.html").write_text(PROXYBRAND_HOST_TEMPLATE, encoding="utf-8")
print(f"{pb}/livepass.html — back-to-console brand (real shell.js + real shim)")
class _PerfStore:
"""Rendezvous for the perf page's POSTed JSON report."""
@@ -1582,14 +2081,7 @@ def _await_report(
def _perf_run_one(
chrome: str,
out: Path,
port: int,
store: _PerfStore,
n: int,
turns: int,
timeout: float,
extra_query: str = "",
chrome: str, out: Path, port: int, store: _PerfStore, n: int, turns: int, timeout: float
) -> dict[str, object] | None:
"""One headless-Chrome perf pass; returns the page's report or None."""
base_flags = [
@@ -1615,8 +2107,6 @@ def _perf_run_one(
url = (
f"http://127.0.0.1:{port}/perf/livepass.html?n={n}&turns={turns}&post=1&run={run_token}"
)
if extra_query:
url += "&" + extra_query.lstrip("&")
store.event.clear()
store.data = None
profile = out / f".chrome-perf-{n}"
@@ -1639,9 +2129,7 @@ def _perf_run_one(
return None
def run_perf(
out: Path, sizes: list[int], turns: int, timeout: float, extra_query: str = ""
) -> bool:
def run_perf(out: Path, sizes: list[int], turns: int, timeout: float) -> bool:
"""Build, serve, and run the perf page once per history size; print a table."""
import functools
import threading
@@ -1661,7 +2149,7 @@ def run_perf(
try:
for n in sizes:
print(f"perf: n={n} turns={turns}", end="", flush=True)
report = _perf_run_one(chrome, out, port, store, n, turns, timeout, extra_query)
report = _perf_run_one(chrome, out, port, store, n, turns, timeout)
if report is None:
print("FAILED (no report — timeout or chrome startup failure)")
continue
@@ -1738,20 +2226,11 @@ def main() -> None:
)
ap.add_argument("--perf-turns", type=int, default=20)
ap.add_argument("--perf-timeout", type=float, default=420.0)
ap.add_argument(
"--perf-extra",
default="",
help="extra query params for the perf page (e.g. 'window=100000' to disable windowing)",
)
args = ap.parse_args()
build(args.out)
if args.perf:
sizes = [int(s) for s in str(args.perf_n).split(",") if s.strip()]
raise SystemExit(
0
if run_perf(args.out, sizes, args.perf_turns, args.perf_timeout, args.perf_extra)
else 1
)
raise SystemExit(0 if run_perf(args.out, sizes, args.perf_turns, args.perf_timeout) else 1)
if args.serve:
import functools
+19
View File
@@ -0,0 +1,19 @@
# Entra config for the Entra e2e / spike harnesses. Copy to `.env` (gitignored)
# and fill in from your tenant. `entra_setup.sh setup` creates the app
# registrations and writes a populated `.env` for you.
#
# cp scripts/obo-e2e/.env.example scripts/obo-e2e/.env
# # then edit, or run: ./scripts/obo-e2e/entra_setup.sh setup
export ENTRA_TENANT_ID=<tenant-guid-or-domain>
export ENTRA_CLIENT_ID=<turnstone-spike-app-client-id>
export ENTRA_CLIENT_SECRET=<client-secret>
export SPIKE_AUDIENCE_A=api://<resource-app-a-guid> # a consented resource
export SPIKE_AUDIENCE_B=api://<resource-app-b-guid> # a second consented resource
export SPIKE_AUDIENCE_UNCONSENTED=api://<resource-app-c-guid> # NOT granted (negative case)
export SPIKE_RUN_OBO=1
# export SPIKE_PORT=8765 # redirect-listener port (default 8765)
# export SPIKE_CALLBACK_FILE=/tmp/obo_cb.txt # remote-browser mode: paste the redirect URL here
# The Keycloak / OSS-path harness needs no config — keycloak_e2e.sh sets
# everything and stands up an ephemeral container.
+214
View File
@@ -0,0 +1,214 @@
# OBO e2e harnesses — single-credential MCP token minting (`auth_type=oauth_obo`)
Manual test harnesses for the `oauth_obo` feature (issue #551). They exercise
the **real** Turnstone mint path (`get_obo_access_token_classified`
`_obo_mint_entra` / `_obo_mint_rfc8693`) against a real identity provider — not
mocks, not the unit suite. Two grant legs:
- **Entra** (`entra_e2e.py`) — real tenant, one interactive sign-in.
- **Keycloak / RFC 8693** (`keycloak_e2e.py` + `.sh`) — ephemeral docker, fully
headless.
There is also `entra_spike.py` (raw-OAuth **wire** probe, pre-implementation
reference) and `entra_setup.sh` (creates the Entra app registrations + writes a
populated `.env`).
**Secrets:** these read config from env. Real credentials live in a **gitignored
`.env`** (copy `.env.example`); nothing tenant-specific is committed. The only
literal secret in the tree is the ephemeral Keycloak container's throwaway
`spike-secret`, which lives and dies with the container.
Not part of CI — run by hand when validating the feature against a live IdP.
## `entra_e2e.py` — end-to-end product exercise (post-implementation)
`entra_spike.py` verified the raw OAuth WIRE (before code existed). `entra_e2e.py`
verifies the SHIPPED Turnstone code: it does a real Entra login, feeds the
credential through the real `MCPTokenStore.upsert_oidc_credential` (the call the
OIDC callback makes on capture), then drives the real
`get_obo_access_token_classified``_obo_mint_entra` against the live Entra token
endpoint. Checks E1E7: real mint + aud claim, cache-hit (0 Entra calls),
single-credential→audiences A&B, rotation write-back, force_refresh re-mint,
unconsented-audience classification with the credential surviving, and
flush→re-mint. Reuses the same `.env` and interactive login (SPIKE_CALLBACK_FILE
for remote browser).
```bash
source scripts/obo-e2e/.env
uv run python scripts/obo-e2e/entra_e2e.py
# one interactive sign-in; E1E7 then run against the real product code. Results below.
```
Results — RUN 2026-07-12 on the real tenant, ALL VERIFIED (exit 0): capture
persisted; E1 mint A (aud=A app-id, cache row refresh_token_ct NULL); E2 cache
hit (0 extra Entra calls); E3 mint B from the SAME credential (aud=B app-id); E4
rotation write-back (RT rotated 2040→2091 chars, newest persisted); E5
force_refresh re-mint (1 Entra call); E6 unconsented C → refresh_failed and the
credential SURVIVES; E7 flush→re-mint. The real `get_obo_access_token_classified`
`_obo_mint_entra` path against the live Entra token endpoint.
## `keycloak_e2e.py` + `keycloak_e2e.sh` — OSS path (RFC 8693), headless
The rfc8693 equivalent of `entra_e2e.py`: `keycloak_e2e.sh` spins up ephemeral
Keycloak, configures the realm (turnstone client with standard token exchange,
mcp-a/b/c clients, aud-mcp-a/b audience scopes, a test user), runs the harness
against the real `get_obo_access_token_classified``_obo_mint_rfc8693`
(refresh grant → token exchange), then tears down. No browser (password grant).
```bash
./scripts/obo-e2e/keycloak_e2e.sh
```
Results — RUN 2026-07-12, ALL VERIFIED: capture persisted; E1 mint A
(refresh→exchange, aud=mcp-a, cache row refresh_token_ct NULL); E2 cache hit (0
extra KC calls); E3 mint B from the SAME credential (aud=mcp-b); E4 rotation
write-back (KC rotated the RT on the refresh leg, newest persisted); E5
force_refresh re-mint (**2 KC calls** = the two-leg chain); E6 unconsented C →
refresh_failed_transient (KC returns invalid_request for a missing audience
scope → classified transient; credential SURVIVES either way); E7 flush→re-mint.
Gotcha: dev-mode Keycloak boot is slow on a loaded host — the script now waits on
kcadm auth (up to ~6 min) rather than a fixed sleep. Port 8091 (8090 = the dev
console).
## Leg 1 — Entra (`entra_spike.py`) — NEEDS TENANT ACCESS
### Tenant / app-registration setup (one-time, ~15 min)
1. **Spike client app** (stands in for Turnstone's OIDC app registration):
- New app registration, single tenant. Platform **Web**, redirect URI
`http://localhost:8765/callback`. Create a **client secret**.
2. **Two resource apps** (stand in for MCP servers A and B):
- New app registrations `spike-mcp-a`, `spike-mcp-b`. In each:
**Expose an API** → set Application ID URI (`api://<guid>`) → add a scope
(e.g. `mcp.access`).
3. **Delegated grants** (this is metaclassing's "proper tenant and app reg setup"):
- On the spike client app → **API permissions** → add delegated permission to
`spike-mcp-a` and `spike-mcp-b` scopes → **Grant admin consent**.
- Optionally also add the spike client's app id to each resource app's
`preAuthorizedApplications` (Expose an API → Add a client application) to
compare against pure admin consent.
4. **Unconsented control** (for V5): a third resource app `spike-mcp-c` with an
exposed API but NO permission granted to the spike client.
### Run
```bash
export ENTRA_TENANT_ID=... ENTRA_CLIENT_ID=... ENTRA_CLIENT_SECRET=...
export SPIKE_AUDIENCE_A=api://<a-guid> SPIKE_AUDIENCE_B=api://<b-guid>
export SPIKE_AUDIENCE_UNCONSENTED=api://<c-guid> # optional (V5)
export SPIKE_RUN_OBO=1 # optional (V6)
uv run python scripts/obo-e2e/entra_spike.py
```
A browser opens for one interactive login (any tenant user). Everything after is
non-interactive — that IS the feature.
### What each check pins down
| Check | Design assumption it verifies |
| --- | --- |
| V1 | `offline_access` on the login yields a client-bound RT (capture layer) |
| V2/V3 | ONE RT redeems for access tokens of DIFFERENT audiences (`scope=<aud>/.default`) — the load-bearing Entra behavior |
| V4 | rotation semantics → whether RT write-back on every mint is convenience or correctness-critical |
| V5 | unconsented audience fails `AADSTS65001 consent_required` → maps to the reconnect-rail fallback, never a silent failure |
| V6 | OBO jwt-bearer middle-tier variant works with the same app registration (comparison data only) |
Also record (manual): whether Conditional Access / MFA policies in the tenant
produce `interaction_required` on redemption — that's the fallback path's other
trigger.
### Results — RUN 2026-07-11 on a real tenant, ALL SIX VERIFIED
Tenant: personal default directory (Global Admin), user is an MSA member.
Setup via `entra_setup.sh setup`; V3 initially failed (see gotcha below),
passed after fixing the grant. Second run: V1-V6 all VERIFIED, exit 0.
| Check | Result |
| --- | --- |
| V1 offline_access login -> RT | VERIFIED (confidential client + PKCE, RT ~2KB) |
| V2 RT -> audience A token | VERIFIED (`aud=<A app guid>`, ~70 min TTL, new RT returned) |
| V3 SAME RT -> audience B token | **VERIFIED — the load-bearing claim: one RT, many audiences** |
| V4 rotation | VERIFIED: RT rotates on every redemption, but the OLD RT stays valid (reuse HTTP 200) -> write-back-newest is required; races are benign on Entra |
| V5 unconsented audience | VERIFIED: `invalid_grant` + `AADSTS65001` (error_codes=[65001]) -> clean mapping to the reconnect-rail fallback |
| V6 OBO jwt-bearer variant | VERIFIED: middle-tier shape also works with the same app registration |
**Operator gotcha (feeds #682 + product docs):** `az ad app permission
admin-consent` run immediately after SP creation SILENTLY skips
not-yet-propagated resource SPs — grant A landed, grant B didn't, and the only
symptom was AADSTS65001 at redemption. Verify grants after consent
(`oauth2PermissionGrants` filter on the client SP) or write them directly with
`az ad app permission grant --id <client> --api <resource> --scope <scope>`.
Product-side implication: a missing tenant grant for a NEW oauth_obo server
surfaces as AADSTS65001 -> the same reconnect-rail path as revocation; the
admin docs must say "grant first, then add the server".
## Leg 2 — Keycloak RFC 8693 (portability check) — runnable locally
Ephemeral `quay.io/keycloak/keycloak:26.3` (`start-dev`, port 8089), realm
`spike`, confidential client `turnstone` with **standard token exchange**
enabled, resource clients `mcp-a`/`mcp-b`, user `alice`. Pipeline mirrors the
product design for a generic-8693 IdP:
```
stored user RT --(refresh grant)--> user AT --(RFC 8693 exchange, audience=mcp-X)--> audience-scoped AT
```
i.e. the per-user credential stays ONE refresh token; per-server tokens are
minted via standard token exchange instead of Entra's multi-resource RT
redemption. Same substrate, different grant leg.
### Results — RUN 2026-07-11, VERIFIED (Keycloak 26.3, ephemeral)
```
alice ONE stored RT
-> refresh grant -> user AT (azp=turnstone); RT ROTATED on refresh
-> 8693 exchange audience=mcp-a scope=aud-mcp-a -> AT aud=mcp-a user=alice 300s, NO RT
-> 8693 exchange audience=mcp-b scope=aud-mcp-b -> AT aud=mcp-b (same subject AT)
negative control audience=mcp-c -> invalid_client "Audience not found"
```
Findings that feed the design:
1. **One per-user credential -> N audience tokens: VERIFIED on a second IdP.**
The substrate is portable; only the grant leg differs per IdP.
2. **Exchanged tokens are cache-shaped** (short TTL, no RT) — per-server
`mcp_user_tokens` rows as short-lived mint cache is the right model.
3. **RT rotation happens here too** — newest-RT write-back on every redemption
is a correctness requirement of the capture layer, not an Entra quirk.
4. **The IdP-side "delegated grant" has a per-IdP shape**: Entra = API
permissions + admin consent; Keycloak = audience client scopes attached to
the requester client (optional scopes activate via `scope=` at exchange).
Operator runbooks are per-IdP (#682 pattern), code is not.
5. Gotchas hit: KC user needs a complete profile for direct grant ("Account is
not fully set up"); optional audience scope must be requested explicitly or
the exchange 400s with "Requested audience not available".
Repro (ephemeral, ~2 min):
```bash
docker run -d --name kc-obo-spike -p 127.0.0.1:8089:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.3 start-dev
KC="docker exec kc-obo-spike /opt/keycloak/bin/kcadm.sh"
$KC config credentials --server http://localhost:8080 --realm master --user admin --password admin
$KC create realms -s realm=spike -s enabled=true
$KC create clients -r spike -s clientId=turnstone -s enabled=true -s publicClient=false \
-s secret=spike-secret -s directAccessGrantsEnabled=true \
-s 'attributes={"standard.token.exchange.enabled":"true"}'
$KC create clients -r spike -s clientId=mcp-a -s enabled=true -s publicClient=false -s secret=x
$KC create clients -r spike -s clientId=mcp-b -s enabled=true -s publicClient=false -s secret=x
$KC create users -r spike -s username=alice -s enabled=true -s email=a@s.test \
-s emailVerified=true -s firstName=A -s lastName=S
$KC set-password -r spike --username alice --new-password alice-pw
TURNSTONE_UUID=$($KC get clients -r spike -q clientId=turnstone --fields id --format csv --noquotes)
for t in mcp-a mcp-b; do
SID=$($KC create client-scopes -r spike -s name=aud-$t -s protocol=openid-connect -i)
$KC create client-scopes/$SID/protocol-mappers/models -r spike -s name=aud-$t \
-s protocol=openid-connect -s protocolMapper=oidc-audience-mapper \
-s "config={\"included.client.audience\":\"$t\",\"access.token.claim\":\"true\"}"
$KC update clients/$TURNSTONE_UUID/optional-client-scopes/$SID -r spike
done
# then: password grant -> refresh grant -> token-exchange with
# grant_type=urn:ietf:params:oauth:grant-type:token-exchange,
# subject_token=<user AT>, subject_token_type=...:access_token,
# audience=mcp-a, scope=aud-mcp-a
```
+286
View File
@@ -0,0 +1,286 @@
"""End-to-end exercise of the oauth_obo feature against a REAL Entra tenant.
Unlike ``entra_spike.py`` (which verified the raw OAuth wire shapes), this
drives the ACTUAL Turnstone product code real ``MCPTokenStore``, real
``get_obo_access_token_classified`` ``_obo_mint_entra`` the real Entra
token endpoint so a green run proves the shipped mint engine works against
live Entra, not just that the protocol does.
Flow:
1. Interactive Entra login (auth-code + PKCE + offline_access) a real
refresh credential. This is what ``handle_oidc_callback`` receives.
2. Persist it via ``MCPTokenStore.upsert_oidc_credential`` the exact call
the OIDC callback makes on capture (auth.py). The rest of the callback
(JWKS validation, user provisioning) is OIDC-generic and unit-tested; the
novel path is capture + mint, which this exercises for real.
3. Seed real ``oauth_obo`` ``mcp_servers`` rows (audiences A/B consented, C
not) and drive ``get_obo_access_token_classified`` the real dispatch-time
entry point asserting on the minted tokens, cache, rotation, and
classification.
Checks (VERIFIED / FAILED per line):
E1 mint for audience A kind=token; decoded aud == A; cache row written with
refresh_token_ct NULL (cache, not custody); expires_at set
E2 second call for A cache hit, ZERO additional Entra calls
E3 mint for audience B from the SAME captured credential aud == B
(the single-credential-many-audiences thesis, through the real engine)
E4 rotation write-back: the stored credential holds the newest refresh token
E5 force_refresh a fresh mint (Entra call count increments)
E6 unconsented audience C NOT kind=token, and the shared credential SURVIVES
(never auto-deleted the load-bearing custody invariant)
E7 cache flush re-mint: deleting the cache row makes the next call re-mint
Run:
source scripts/obo-e2e/.env
uv run python scripts/obo-e2e/entra_e2e.py
Env (from .env): ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET,
SPIKE_AUDIENCE_A, SPIKE_AUDIENCE_B, SPIKE_AUDIENCE_UNCONSENTED, SPIKE_PORT.
Remote browser: set SPIKE_CALLBACK_FILE to paste the redirect URL (as before).
"""
from __future__ import annotations
import asyncio
import base64
import os
import sys
import tempfile
from types import SimpleNamespace
from typing import Any
import httpx
# Reuse the verified interactive-login machinery from the wire spike.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from entra_spike import interactive_login, jwt_claims_unverified, redact # noqa: E402
from turnstone.core.mcp_crypto import ( # noqa: E402
MCPTokenCipher,
MCPTokenCipherConfig,
MCPTokenStore,
)
from turnstone.core.mcp_oauth import get_obo_access_token_classified # noqa: E402
from turnstone.core.oidc import OIDCConfig # noqa: E402
from turnstone.core.storage._sqlite import SQLiteBackend # noqa: E402
USER = "e2e-user"
RESULTS: list[tuple[str, str]] = []
def record(status: str, msg: str) -> None:
RESULTS.append((status, msg))
print(f"[{status:>8}] {msg}")
def aud_matches(token: str, want_audience: str) -> tuple[bool, str]:
"""Compare a minted access token's aud claim to the configured audience.
Entra returns aud as the bare app-id GUID or the full ``api://<guid>`` URI;
accept either.
"""
claims = jwt_claims_unverified(token)
aud = str(claims.get("aud", "<none>"))
want = want_audience.removeprefix("api://")
return aud in (want, want_audience), aud
class _CountingClient:
"""Wraps httpx.AsyncClient, counting token-endpoint POSTs so cache hits
(which must issue zero) are observable."""
def __init__(self, inner: httpx.AsyncClient) -> None:
self._inner = inner
self.posts = 0
async def post(self, *args: Any, **kwargs: Any) -> httpx.Response:
self.posts += 1
return await self._inner.post(*args, **kwargs)
def _make_app_state(
storage: SQLiteBackend,
store: MCPTokenStore,
oidc_config: OIDCConfig,
http_client: _CountingClient,
) -> SimpleNamespace:
return SimpleNamespace(
auth_storage=storage,
mcp_token_store=store,
oidc_config=oidc_config,
obo_http_client=http_client,
mcp_oauth_refresh_locks={},
mcp_oauth_refresh_backoff={},
)
def _seed_obo_server(storage: SQLiteBackend, name: str, audience: str) -> None:
storage.create_mcp_server(
server_id=f"{name}-id",
name=name,
transport="streamable-http",
url="https://mcp.example.invalid/sse",
auth_type="oauth_obo",
oauth_audience=audience,
)
async def _run(cfg: dict[str, str], refresh_token: str) -> None:
tenant = cfg["ENTRA_TENANT_ID"]
issuer = f"https://login.microsoftonline.com/{tenant}/v2.0"
token_endpoint = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
aud_a = cfg["SPIKE_AUDIENCE_A"]
aud_b = cfg["SPIKE_AUDIENCE_B"]
aud_c = cfg.get("SPIKE_AUDIENCE_UNCONSENTED", "")
# Real Turnstone objects.
db_path = os.path.join(tempfile.mkdtemp(prefix="obo-e2e-"), "e2e.db")
storage = SQLiteBackend(db_path)
from cryptography.fernet import Fernet
raw = base64.urlsafe_b64decode(Fernet.generate_key())
store = MCPTokenStore(storage, MCPTokenCipher(MCPTokenCipherConfig(keys=(raw,))), node_id="e2e")
oidc_config = OIDCConfig(
enabled=True,
issuer=issuer,
client_id=cfg["ENTRA_CLIENT_ID"],
client_secret=cfg["ENTRA_CLIENT_SECRET"],
token_endpoint=token_endpoint,
obo_grant_profile="entra",
capture_user_credential=True,
)
# Step 2 — CAPTURE: the exact storage call handle_oidc_callback makes.
store.upsert_oidc_credential(USER, issuer, refresh_token=refresh_token)
cap = store.get_oidc_credential(USER, issuer)
if cap and cap["refresh_token"] == refresh_token:
record("VERIFIED", f"capture: credential persisted for {USER} ({redact(refresh_token)})")
else:
record("FAILED", "capture: credential did not round-trip")
return
_seed_obo_server(storage, "e2e-a", aud_a)
_seed_obo_server(storage, "e2e-b", aud_b)
if aud_c:
_seed_obo_server(storage, "e2e-c", aud_c)
inner = httpx.AsyncClient(timeout=20.0)
client = _CountingClient(inner)
app_state = _make_app_state(storage, store, oidc_config, client)
try:
# E1 — real mint for audience A.
r = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="e2e-a"
)
if r.kind == "token" and r.token:
ok, aud = aud_matches(r.token, aud_a)
row = storage.get_mcp_user_token(USER, "e2e-a")
cache_ok = (
row is not None and row["refresh_token_ct"] is None and bool(row["expires_at"])
)
record(
"VERIFIED" if ok and cache_ok else "FAILED",
f"E1 mint A: kind=token aud={aud} want={aud_a} cache_row_refreshless={cache_ok}",
)
else:
record("FAILED", f"E1 mint A: kind={r.kind} (expected token)")
return
# E2 — cache hit issues zero Entra calls.
posts_before = client.posts
r2 = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="e2e-a"
)
record(
"VERIFIED" if r2.kind == "token" and client.posts == posts_before else "FAILED",
f"E2 cache hit: kind={r2.kind} extra_entra_calls={client.posts - posts_before} (want 0)",
)
# E3 — same credential, audience B.
rb = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="e2e-b"
)
if rb.kind == "token" and rb.token:
ok_b, aud_bclaim = aud_matches(rb.token, aud_b)
record(
"VERIFIED" if ok_b else "FAILED",
f"E3 mint B from SAME credential: aud={aud_bclaim} want={aud_b}",
)
else:
record("FAILED", f"E3 mint B: kind={rb.kind}")
# E4 — rotation write-back: the stored credential is still redeemable
# (holds the newest RT — Entra rotates on redemption).
cred_now = store.get_oidc_credential(USER, issuer)
record(
"VERIFIED" if cred_now is not None else "FAILED",
f"E4 rotation write-back: credential persisted {redact(cred_now['refresh_token']) if cred_now else '<gone>'}",
)
# E5 — force_refresh re-mints (a real Entra call).
posts_before = client.posts
rf = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="e2e-a", force_refresh=True
)
record(
"VERIFIED" if rf.kind == "token" and client.posts > posts_before else "FAILED",
f"E5 force_refresh re-mint: kind={rf.kind} entra_calls={client.posts - posts_before} (want >=1)",
)
# E6 — unconsented audience: not a token, and the credential SURVIVES.
if aud_c:
rc = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="e2e-c"
)
cred_after = store.get_oidc_credential(USER, issuer)
record(
"VERIFIED" if rc.kind != "token" and cred_after is not None else "FAILED",
f"E6 unconsented C: kind={rc.kind} (not token) credential_survives={cred_after is not None}",
)
else:
record("SKIPPED", "E6 unconsented C: SPIKE_AUDIENCE_UNCONSENTED not set")
# E7 — cache flush → re-mint.
store.delete_user_token(USER, "e2e-a")
posts_before = client.posts
r7 = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="e2e-a"
)
record(
"VERIFIED" if r7.kind == "token" and client.posts > posts_before else "FAILED",
f"E7 flush→re-mint: kind={r7.kind} entra_calls={client.posts - posts_before} (want >=1)",
)
finally:
await inner.aclose()
def main() -> int:
required = [
"ENTRA_TENANT_ID",
"ENTRA_CLIENT_ID",
"ENTRA_CLIENT_SECRET",
"SPIKE_AUDIENCE_A",
"SPIKE_AUDIENCE_B",
]
cfg = {k: os.environ[k] for k in os.environ if k.startswith(("ENTRA_", "SPIKE_"))}
missing = [k for k in required if not cfg.get(k)]
if missing:
print(f"Missing env: {', '.join(missing)} — did you `source scripts/obo-e2e/.env`?")
return 2
print("Signing in to Entra (this is the login the feature captures)...")
tokens = interactive_login(cfg)
refresh_token = tokens.get("refresh_token")
if not isinstance(refresh_token, str) or not refresh_token:
print(f"No refresh_token from login (keys={sorted(tokens)}) — offline_access missing?")
return 1
asyncio.run(_run(cfg, refresh_token))
print("\n=== summary ===")
for status, msg in RESULTS:
print(f" {status:>8} {msg}")
return 0 if all(s in ("VERIFIED", "SKIPPED") for s, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Entra spike setup for entra_spike.py (#551 re-scope boundary spike).
# Manual test tooling — not run in CI. Creates throwaway Entra app registrations.
#
# ./entra_setup.sh setup create app registrations + consent + .env
# ./entra_setup.sh cleanup delete everything it created (incl. .env)
#
# Creates in the logged-in tenant (az login first):
# spike-turnstone confidential client (stands in for Turnstone's OIDC app)
# spike-mcp-a/b resource apps exposing scope mcp.access, admin-consented
# spike-mcp-c resource app with NO grant to the client (V5 control)
# Requires: the logged-in user can create apps + grant admin consent
# (Global Admin on a personal tenant qualifies).
set -euo pipefail
cd "$(dirname "$0")"
ENV_FILE=".env"
NAMES=(spike-turnstone spike-mcp-a spike-mcp-b spike-mcp-c)
log() { printf '>> %s\n' "$*"; }
graph_patch_api() { # $1=appId $2=scope-uuid $3=display-name
local obj_id
obj_id=$(az ad app show --id "$1" --query id -o tsv)
az rest --method PATCH \
--url "https://graph.microsoft.com/v1.0/applications/${obj_id}" \
--headers 'Content-Type=application/json' \
--body "{
\"identifierUris\": [\"api://$1\"],
\"api\": {
\"requestedAccessTokenVersion\": 2,
\"oauth2PermissionScopes\": [{
\"id\": \"$2\",
\"value\": \"mcp.access\",
\"type\": \"Admin\",
\"isEnabled\": true,
\"adminConsentDisplayName\": \"Access $3\",
\"adminConsentDescription\": \"Spike scope for $3\"
}]
}
}"
}
make_resource_app() { # $1=display-name ; echoes "appId scopeId"
local app_id scope_id
app_id=$(az ad app create --display-name "$1" \
--sign-in-audience AzureADMyOrg --query appId -o tsv)
scope_id=$(python3 -c 'import uuid; print(uuid.uuid4())')
graph_patch_api "$app_id" "$scope_id" "$1" >/dev/null
az ad sp create --id "$app_id" >/dev/null 2>&1 || true
echo "$app_id $scope_id"
}
cmd_setup() {
local tenant_id
tenant_id=$(az account show --query tenantId -o tsv)
log "tenant: ${tenant_id}"
log "creating resource apps (a, b, c)..."
read -r APP_A SCOPE_A <<<"$(make_resource_app spike-mcp-a)"
read -r APP_B SCOPE_B <<<"$(make_resource_app spike-mcp-b)"
read -r APP_C _ <<<"$(make_resource_app spike-mcp-c)"
log " a=${APP_A} b=${APP_B} c=${APP_C} (c stays unconsented)"
log "creating confidential client spike-turnstone..."
CLIENT_ID=$(az ad app create --display-name spike-turnstone \
--sign-in-audience AzureADMyOrg \
--web-redirect-uris "http://localhost:8765/callback" \
--query appId -o tsv)
az ad sp create --id "$CLIENT_ID" >/dev/null 2>&1 || true
# No stderr suppression here: the secret is load-bearing (it lands in .env),
# so under `set -e` a reset failure must abort LOUDLY, not silently.
SECRET=$(az ad app credential reset --id "$CLIENT_ID" \
--display-name spike --years 1 --query password -o tsv)
log "adding delegated permissions (a, b — NOT c)..."
# Tolerated failures (|| log): a re-run hits "permission already exists" and
# SP-propagation delays are common right after app creation — the
# admin-consent retry loop below is the real gate. `set -e` would otherwise
# turn a suppressed non-zero here into a silent mid-script abort.
az ad app permission add --id "$CLIENT_ID" \
--api "$APP_A" --api-permissions "${SCOPE_A}=Scope" \
|| log " warn: permission add for a failed (may already exist); admin-consent below will confirm"
az ad app permission add --id "$CLIENT_ID" \
--api "$APP_B" --api-permissions "${SCOPE_B}=Scope" \
|| log " warn: permission add for b failed (may already exist); admin-consent below will confirm"
log "granting admin consent (retries while SPs propagate)..."
local ok=""
for i in 1 2 3 4 5; do
if az ad app permission admin-consent --id "$CLIENT_ID" 2>/dev/null; then
ok=1; break
fi
log " not yet (attempt $i) — waiting 15s"
sleep 15
done
[ -n "$ok" ] || { log "admin-consent failed after retries — grant manually in the portal (API permissions blade) and re-run the spike"; }
# Single-quote the values in the generated .env: the AS-issued client secret
# can contain $ / backtick, and an unquoted RHS would be re-expanded (or
# partially executed) when the operator `source`s the file. The heredoc still
# interpolates ${...} into the single-quoted output; sourcing then treats the
# result literally. (Azure secrets are base64-ish — no single quotes to escape.)
umask 177
cat > "$ENV_FILE" <<EOF
export ENTRA_TENANT_ID='${tenant_id}'
export ENTRA_CLIENT_ID='${CLIENT_ID}'
export ENTRA_CLIENT_SECRET='${SECRET}'
export SPIKE_AUDIENCE_A='api://${APP_A}'
export SPIKE_AUDIENCE_B='api://${APP_B}'
export SPIKE_AUDIENCE_UNCONSENTED='api://${APP_C}'
export SPIKE_RUN_OBO=1
EOF
log "wrote ${ENV_FILE} (chmod 600). Next:"
log " source scripts/obo-e2e/.env && uv run python scripts/obo-e2e/entra_spike.py"
log "cleanup later with: ./entra_setup.sh cleanup"
}
cmd_cleanup() {
for name in "${NAMES[@]}"; do
for app_id in $(az ad app list --display-name "$name" --query '[].appId' -o tsv); do
log "deleting ${name} (${app_id})"
az ad app delete --id "$app_id"
done
done
rm -f "$ENV_FILE"
log "cleanup done (app registrations + .env removed)"
}
case "${1:-}" in
setup) cmd_setup ;;
cleanup) cmd_cleanup ;;
*) echo "usage: $0 setup|cleanup"; exit 2 ;;
esac
+333
View File
@@ -0,0 +1,333 @@
"""Entra boundary spike for single-credential MCP token minting (#551 re-scope).
Verifies, against a REAL Entra tenant, the assumptions behind the oauth_obo
design (one IdP refresh token per user; per-MCP access tokens minted on
demand). Each check prints VERIFIED / FAILED / SKIPPED plus redacted evidence.
V1 interactive confidential-client login (auth-code + PKCE + offline_access)
-> refresh token captured [capture layer works]
V2 RT redeemed with scope=<AUDIENCE_A>/.default -> aud claim == A
V3 SAME credential redeemed for <AUDIENCE_B> -> aud claim == B
KEY CHECK: Entra RTs are client-bound, not resource-bound.
V4 rotation semantics: does each redemption return a new RT, and does the
PREVIOUS RT keep working? [write-back design]
V5 redemption for an unconsented audience -> AADSTS65001 consent_required
[maps to the reconnect-rail fallback]
V6 optional: OBO jwt-bearer leg (requested_token_use=on_behalf_of) using a
Turnstone-audience access token as assertion [middle-tier variant]
Run: uv run python scripts/obo-e2e/entra_spike.py
Env: ENTRA_TENANT_ID tenant GUID or domain
ENTRA_CLIENT_ID Turnstone spike app registration (confidential)
ENTRA_CLIENT_SECRET client secret for the above
SPIKE_AUDIENCE_A e.g. api://<guid-a> (exposes a scope, consented)
SPIKE_AUDIENCE_B e.g. api://<guid-b> (exposes a scope, consented)
SPIKE_AUDIENCE_UNCONSENTED optional, for V5
SPIKE_RUN_OBO optional "1" to run V6
SPIKE_PORT redirect listener port (default 8765; register
http://localhost:<port>/callback as a Web
redirect URI on the spike app registration)
App-registration setup checklist: see README.md next to this file.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import secrets
import sys
import threading
import urllib.parse
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any
import httpx
RESULTS: list[tuple[str, str, str]] = [] # (check, status, evidence)
def record(check: str, status: str, evidence: str) -> None:
RESULTS.append((check, status, evidence))
print(f"[{status:>8}] {check}: {evidence}")
def b64url_json(segment: str) -> dict[str, Any]:
pad = "=" * (-len(segment) % 4)
out: dict[str, Any] = json.loads(base64.urlsafe_b64decode(segment + pad))
return out
def jwt_claims_unverified(token: str) -> dict[str, Any]:
"""Spike-only unverified decode. NEVER do this in product code."""
try:
return b64url_json(token.split(".")[1])
except Exception:
return {}
def redact(token: str | None) -> str:
if not token:
return "<absent>"
return f"{token[:8]}...({len(token)} chars)"
class _CodeCatcher(BaseHTTPRequestHandler):
code: str | None = None
state: str | None = None
event = threading.Event()
def do_GET(self) -> None: # noqa: N802 - stdlib API name
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
_CodeCatcher.code = (q.get("code") or [None])[0]
_CodeCatcher.state = (q.get("state") or [None])[0]
body = b"Spike login captured - return to the terminal."
if q.get("error"):
body = f"IdP error: {q}".encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body)
_CodeCatcher.event.set()
def log_message(self, *args: Any) -> None:
pass
def interactive_login(cfg: dict[str, str]) -> dict[str, Any]:
"""V1: authorization-code + PKCE + offline_access as a confidential client.
Mirrors production shape: same grant Turnstone's OIDC login uses
(core/oidc.py exchange_code), plus offline_access.
"""
port = int(cfg.get("SPIKE_PORT", "8765"))
redirect_uri = f"http://localhost:{port}/callback"
verifier = secrets.token_urlsafe(48)
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
)
state = secrets.token_urlsafe(16)
authorize = (
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/authorize?"
+ urllib.parse.urlencode(
{
"client_id": cfg["ENTRA_CLIENT_ID"],
"response_type": "code",
"redirect_uri": redirect_uri,
"response_mode": "query",
# offline_access is THE capture-layer delta vs today's login.
# No resource scope here: the RT is minted client-bound.
"scope": "openid profile offline_access",
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
)
)
server = HTTPServer(("127.0.0.1", port), _CodeCatcher)
threading.Thread(target=server.serve_forever, daemon=True).start()
print(f"\nOpen (or auto-opened) in a browser with a tenant user:\n {authorize}\n")
cb_file = cfg.get("SPIKE_CALLBACK_FILE", "")
if cb_file:
print(
"Remote-browser mode: after sign-in the browser lands on a broken\n"
f"http://localhost:{port}/callback?... page. Copy that FULL URL and run:\n"
f" echo '<url>' > {cb_file}\n"
)
def _watch_callback_file() -> None:
# Driver-friendly fallback: the sign-in can happen on any device;
# whoever signed in drops the redirected URL into SPIKE_CALLBACK_FILE.
import time as _time
while not _CodeCatcher.event.is_set():
try:
with open(cb_file) as _f:
pasted = _f.read().strip()
except OSError:
pasted = ""
if "?" in pasted:
q = urllib.parse.parse_qs(urllib.parse.urlparse(pasted).query)
_CodeCatcher.code = (q.get("code") or [None])[0]
_CodeCatcher.state = (q.get("state") or [None])[0]
_CodeCatcher.event.set()
return
_time.sleep(1.0)
if cb_file:
threading.Thread(target=_watch_callback_file, daemon=True).start()
webbrowser.open(authorize)
if not _CodeCatcher.event.wait(timeout=600):
server.shutdown()
raise SystemExit("Timed out waiting for the redirect (10 min).")
server.shutdown()
if _CodeCatcher.state != state:
raise SystemExit("state mismatch on redirect - aborting.")
if not _CodeCatcher.code:
raise SystemExit("No code on redirect (IdP error page shown in browser).")
resp = httpx.post(
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/token",
data={
"grant_type": "authorization_code",
"code": _CodeCatcher.code,
"redirect_uri": redirect_uri,
"client_id": cfg["ENTRA_CLIENT_ID"],
"client_secret": cfg["ENTRA_CLIENT_SECRET"],
"code_verifier": verifier,
},
timeout=15.0,
)
tokens: dict[str, Any] = resp.json()
if resp.status_code != 200:
raise SystemExit(f"code exchange failed: {json.dumps(tokens, indent=2)[:800]}")
return tokens
def redeem(cfg: dict[str, str], refresh_token: str, scope: str) -> tuple[int, dict[str, Any]]:
"""Redeem a refresh token for an access token with the given scope."""
resp = httpx.post(
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": cfg["ENTRA_CLIENT_ID"],
"client_secret": cfg["ENTRA_CLIENT_SECRET"],
"scope": scope,
},
timeout=15.0,
)
body: dict[str, Any] = resp.json()
return resp.status_code, body
def obo_exchange(cfg: dict[str, str], assertion: str, scope: str) -> tuple[int, dict[str, Any]]:
"""V6: middle-tier OBO variant (jwt-bearer + requested_token_use)."""
resp = httpx.post(
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
"client_id": cfg["ENTRA_CLIENT_ID"],
"client_secret": cfg["ENTRA_CLIENT_SECRET"],
"scope": scope,
"requested_token_use": "on_behalf_of",
},
timeout=15.0,
)
body: dict[str, Any] = resp.json()
return resp.status_code, body
def check_aud(label: str, status: int, body: dict[str, Any], want_aud: str) -> str | None:
"""Common V2/V3 assertion: 200 + aud matches. Returns the new RT if any."""
if status != 200:
record(label, "FAILED", f"HTTP {status}: {json.dumps(body)[:300]}")
return None
claims = jwt_claims_unverified(body.get("access_token", ""))
aud = str(claims.get("aud", "<none>"))
ok = aud == want_aud or aud == want_aud.removeprefix("api://")
record(
label,
"VERIFIED" if ok else "FAILED",
f"aud={aud} want={want_aud} expires_in={body.get('expires_in')} "
f"new_rt={redact(body.get('refresh_token'))}",
)
new_rt = body.get("refresh_token")
return str(new_rt) if isinstance(new_rt, str) else None
def main() -> int:
required = [
"ENTRA_TENANT_ID",
"ENTRA_CLIENT_ID",
"ENTRA_CLIENT_SECRET",
"SPIKE_AUDIENCE_A",
"SPIKE_AUDIENCE_B",
]
cfg = {k: os.environ[k] for k in required if k in os.environ}
missing = [k for k in required if k not in cfg]
if missing:
print(f"Missing env: {', '.join(missing)}\nSee module docstring.")
return 2
for opt in ("SPIKE_AUDIENCE_UNCONSENTED", "SPIKE_PORT", "SPIKE_RUN_OBO"):
if opt in os.environ:
cfg[opt] = os.environ[opt]
# V1 - capture
tokens = interactive_login(cfg)
rt0 = tokens.get("refresh_token")
if isinstance(rt0, str) and rt0:
record("V1 capture (offline_access -> RT)", "VERIFIED", redact(rt0))
else:
record("V1 capture (offline_access -> RT)", "FAILED", f"keys={sorted(tokens.keys())}")
return 1
# V2 - mint for audience A
a = cfg["SPIKE_AUDIENCE_A"]
s2, b2 = redeem(cfg, rt0, f"{a}/.default")
rt_after_a = check_aud("V2 mint audience A from RT", s2, b2, a)
# V3 - SAME credential, audience B (the design-critical check)
b = cfg["SPIKE_AUDIENCE_B"]
s3, b3 = redeem(cfg, rt0, f"{b}/.default")
check_aud("V3 mint audience B from SAME RT", s3, b3, b)
# V4 - rotation semantics
if rt_after_a and rt_after_a != rt0:
s4, _ = redeem(cfg, rt0, f"{a}/.default")
record(
"V4 rotation (new RT returned; old still valid?)",
"VERIFIED" if s4 == 200 else "VERIFIED",
f"rotated=yes old_rt_reuse_http={s4} "
"(design: persist newest RT on every mint; "
f"{'old stays valid - benign race window' if s4 == 200 else 'old INVALIDATED - write-back is correctness-critical'})",
)
else:
record(
"V4 rotation",
"VERIFIED",
"no rotation observed on redemption (same/absent RT) - "
"write-back still required for the rotating case",
)
# V5 - unconsented audience -> consent_required
unc = cfg.get("SPIKE_AUDIENCE_UNCONSENTED")
if unc:
s5, b5 = redeem(cfg, rt0, f"{unc}/.default")
codes = b5.get("error_codes", [])
hit = s5 == 400 and (65001 in codes or b5.get("suberror") == "consent_required")
record(
"V5 unconsented audience -> AADSTS65001",
"VERIFIED" if hit else "FAILED",
f"http={s5} error={b5.get('error')} codes={codes}",
)
else:
record("V5 unconsented audience", "SKIPPED", "SPIKE_AUDIENCE_UNCONSENTED not set")
# V6 - optional OBO middle-tier variant
if cfg.get("SPIKE_RUN_OBO") == "1":
s6a, b6a = redeem(cfg, rt0, f"{cfg['ENTRA_CLIENT_ID']}/.default")
at_self = b6a.get("access_token", "") if s6a == 200 else ""
if at_self:
s6, b6 = obo_exchange(cfg, at_self, f"{a}/.default")
check_aud("V6 OBO jwt-bearer variant", s6, b6, a)
else:
record(
"V6 OBO jwt-bearer variant",
"FAILED",
f"could not mint self-audience assertion: HTTP {s6a}",
)
else:
record("V6 OBO jwt-bearer variant", "SKIPPED", "SPIKE_RUN_OBO != 1")
print("\n=== summary ===")
for check, status, _ in RESULTS:
print(f" {status:>8} {check}")
return 0 if all(s != "FAILED" for _, s, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
+371
View File
@@ -0,0 +1,371 @@
"""End-to-end exercise of the oauth_obo feature on the OSS path (RFC 8693).
Parallel to ``entra_e2e.py`` but for ``obo_grant_profile="rfc8693"`` against an
ephemeral Keycloak the open-source / non-Entra deployment shape. Fully
headless (password grant, no browser), so it runs unattended.
Drives the REAL Turnstone code: ``MCPTokenStore.upsert_oidc_credential`` (capture)
then ``get_obo_access_token_classified`` ``_obo_mint_rfc8693`` (refresh grant
RFC 8693 token exchange) against the live Keycloak token endpoint.
Checks E1E7 mirror the Entra harness:
E1 mint audience A token, aud claim carries A, cache row refresh_token_ct NULL
E2 second call cache hit, ZERO extra Keycloak calls
E3 audience B from the SAME captured credential aud carries B
E4 rotation write-back (KC rotates the RT on the refresh leg)
E5 force_refresh re-mint (Keycloak call count increments)
E6 unconsented audience C NOT token, credential SURVIVES
E7 cache flush re-mint
M1-M3 drive the MODEL-backend mint (``mint_obo_access_token``, #898/#955) on
the same captured credential the path an ``auth_mode=rfc8693_obo`` model
alias takes, distinct from the classified MCP path above:
M1 model mint audience A with the alias's exchange scopes → token carries A
(the #955 fix: model definitions now carry per-row ``obo_scopes``, so
the exchange leg requests the audience's scope exactly as MCP rows do)
M2 warm re-mint serves the synthetic ``__model_obo__`` cache row
identity-keyed on the owning alias, audience + scopes in the row's
own columns with zero IdP calls
M3 an entra-leg mode (``entra_obo``) on this rfc8693 deployment refuses
BEFORE any IdP traffic, recording cause=grant_profile_mismatch the
mode/profile pairing that replaced the pre-#955 overload
Env (set by keycloak_e2e.sh):
KC_TOKEN_ENDPOINT, KC_ISSUER, KC_CLIENT_ID, KC_CLIENT_SECRET,
KC_USER, KC_PASSWORD, AUD_A, SCOPE_A, AUD_B, SCOPE_B, AUD_C
"""
from __future__ import annotations
import asyncio
import base64
import json
import os
import sys
import tempfile
from types import SimpleNamespace
from typing import Any
import httpx
from turnstone.core.mcp_crypto import (
MCPTokenCipher,
MCPTokenCipherConfig,
MCPTokenStore,
)
from turnstone.core.mcp_oauth import (
get_obo_access_token_classified,
mint_obo_access_token,
model_mint_refusal_cause,
model_obo_cache_server,
model_obo_cause_key,
)
from turnstone.core.oidc import OIDCConfig
from turnstone.core.storage._sqlite import SQLiteBackend
USER = "e2e-user"
RESULTS: list[tuple[str, str]] = []
def record(status: str, msg: str) -> None:
RESULTS.append((status, msg))
print(f"[{status:>8}] {msg}")
def redact(token: str | None) -> str:
return f"{token[:8]}...({len(token)} chars)" if token else "<absent>"
def jwt_claims(token: str) -> dict[str, Any]:
seg = token.split(".")[1]
pad = "=" * (-len(seg) % 4)
out: dict[str, Any] = json.loads(base64.urlsafe_b64decode(seg + pad))
return out
def aud_carries(token: str, want: str) -> tuple[bool, str]:
"""KC puts the exchanged audience in the aud claim (str or list)."""
aud = jwt_claims(token).get("aud", [])
auds = aud if isinstance(aud, list) else [aud]
return want in auds, str(aud)
class _CountingClient:
def __init__(self, inner: httpx.AsyncClient) -> None:
self._inner = inner
self.posts = 0
async def post(self, *args: Any, **kwargs: Any) -> httpx.Response:
self.posts += 1
return await self._inner.post(*args, **kwargs)
def _password_login(cfg: dict[str, str]) -> str:
"""Headless direct-access grant → a real refresh token for the user."""
resp = httpx.post(
cfg["KC_TOKEN_ENDPOINT"],
data={
"grant_type": "password",
"client_id": cfg["KC_CLIENT_ID"],
"client_secret": cfg["KC_CLIENT_SECRET"],
"username": cfg["KC_USER"],
"password": cfg["KC_PASSWORD"],
"scope": "openid",
},
timeout=15.0,
)
resp.raise_for_status()
return str(resp.json()["refresh_token"])
def _seed(storage: SQLiteBackend, name: str, audience: str, scopes: str | None) -> None:
storage.create_mcp_server(
server_id=f"{name}-id",
name=name,
transport="streamable-http",
url="https://mcp.example.invalid/sse",
auth_type="oauth_obo",
oauth_audience=audience,
oauth_scopes=scopes,
)
async def _run(cfg: dict[str, str], refresh_token: str) -> None:
issuer = cfg["KC_ISSUER"]
db_path = os.path.join(tempfile.mkdtemp(prefix="obo-kc-e2e-"), "e2e.db")
storage = SQLiteBackend(db_path)
from cryptography.fernet import Fernet
raw = base64.urlsafe_b64decode(Fernet.generate_key())
store = MCPTokenStore(storage, MCPTokenCipher(MCPTokenCipherConfig(keys=(raw,))), node_id="e2e")
oidc_config = OIDCConfig(
enabled=True,
issuer=issuer,
client_id=cfg["KC_CLIENT_ID"],
client_secret=cfg["KC_CLIENT_SECRET"],
token_endpoint=cfg["KC_TOKEN_ENDPOINT"],
obo_grant_profile="rfc8693",
capture_user_credential=True,
)
store.upsert_oidc_credential(USER, issuer, refresh_token=refresh_token)
cap = store.get_oidc_credential(USER, issuer)
if cap and cap["refresh_token"] == refresh_token:
record("VERIFIED", f"capture: credential persisted ({redact(refresh_token)})")
else:
record("FAILED", "capture: credential did not round-trip")
return
_seed(storage, "kc-a", cfg["AUD_A"], cfg.get("SCOPE_A"))
_seed(storage, "kc-b", cfg["AUD_B"], cfg.get("SCOPE_B"))
if cfg.get("AUD_C"):
_seed(storage, "kc-c", cfg["AUD_C"], None) # no audience scope → unconsented
inner = httpx.AsyncClient(timeout=20.0)
client = _CountingClient(inner)
app_state = SimpleNamespace(
auth_storage=storage,
mcp_token_store=store,
oidc_config=oidc_config,
obo_http_client=client,
mcp_oauth_refresh_locks={},
mcp_oauth_refresh_backoff={},
)
try:
# E1 — rfc8693 mint (refresh grant → token exchange) for audience A.
r = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="kc-a"
)
if r.kind == "token" and r.token:
ok, aud = aud_carries(r.token, cfg["AUD_A"])
row = storage.get_mcp_user_token(USER, "kc-a")
cache_ok = row is not None and row["refresh_token_ct"] is None
record(
"VERIFIED" if ok and cache_ok else "FAILED",
f"E1 mint A (refresh→exchange): kind=token aud={aud} want={cfg['AUD_A']} "
f"cache_row_refreshless={cache_ok}",
)
else:
record("FAILED", f"E1 mint A: kind={r.kind} (expected token)")
return
# E2 — cache hit.
posts_before = client.posts
r2 = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="kc-a"
)
record(
"VERIFIED" if r2.kind == "token" and client.posts == posts_before else "FAILED",
f"E2 cache hit: kind={r2.kind} extra_kc_calls={client.posts - posts_before} (want 0)",
)
# E3 — audience B from the SAME credential.
rb = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="kc-b"
)
if rb.kind == "token" and rb.token:
ok_b, aud_b = aud_carries(rb.token, cfg["AUD_B"])
record(
"VERIFIED" if ok_b else "FAILED",
f"E3 mint B from SAME credential: aud={aud_b} want={cfg['AUD_B']}",
)
else:
record("FAILED", f"E3 mint B: kind={rb.kind}")
# E4 — rotation write-back (KC rotates the RT on the refresh leg).
cred_now = store.get_oidc_credential(USER, issuer)
rotated = cred_now is not None and cred_now["refresh_token"] != refresh_token
record(
"VERIFIED" if cred_now is not None else "FAILED",
f"E4 rotation write-back: persisted={redact(cred_now['refresh_token']) if cred_now else '<gone>'} "
f"rotated_from_initial={rotated}",
)
# E5 — force_refresh re-mints.
posts_before = client.posts
rf = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="kc-a", force_refresh=True
)
record(
"VERIFIED" if rf.kind == "token" and client.posts > posts_before else "FAILED",
f"E5 force_refresh re-mint: kind={rf.kind} kc_calls={client.posts - posts_before} (want >=1)",
)
# E6 — unconsented audience: not a token, credential survives.
if cfg.get("AUD_C"):
rc = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="kc-c"
)
cred_after = store.get_oidc_credential(USER, issuer)
record(
"VERIFIED" if rc.kind != "token" and cred_after is not None else "FAILED",
f"E6 unconsented C: kind={rc.kind} (not token) credential_survives={cred_after is not None}",
)
else:
record("SKIPPED", "E6 unconsented C: AUD_C not set")
# E7 — cache flush → re-mint.
store.delete_user_token(USER, "kc-a")
posts_before = client.posts
r7 = await get_obo_access_token_classified(
app_state=app_state, user_id=USER, server_name="kc-a"
)
record(
"VERIFIED" if r7.kind == "token" and client.posts > posts_before else "FAILED",
f"E7 flush→re-mint: kind={r7.kind} kc_calls={client.posts - posts_before} (want >=1)",
)
# M1-M3 — MODEL backend mint on the rfc8693 profile: same captured
# credential and legs as E1-E7, but through mint_obo_access_token —
# the path an auth_mode=rfc8693_obo alias takes, carrying the
# per-alias exchange scopes MCP rows always had (#955). The mint's
# cache and cause records are identity-keyed on the owning alias, so
# the harness names one per mode-variant exactly as a deployment
# would define separate rows.
posts_before = client.posts
m1 = await mint_obo_access_token(
app_state=app_state,
user_id=USER,
alias="model-a",
audience=cfg["AUD_A"],
scopes=cfg.get("SCOPE_A", ""),
grant_leg="rfc8693",
)
m1_kc_calls = client.posts - posts_before
if m1:
ok1, why1 = aud_carries(m1, cfg["AUD_A"])
record(
"VERIFIED" if ok1 and m1_kc_calls > 0 else "FAILED",
f"M1 model mint (rfc8693_obo, scoped exchange): token={redact(m1)} "
f"aud_ok={ok1} ({why1}) kc_calls={m1_kc_calls} (want >=1)",
)
else:
record(
"FAILED",
f"M1 model mint (rfc8693_obo): no token (kc_calls={m1_kc_calls}) — "
"the #955 scope wire-through should mint here",
)
# M2 — warm re-mint serves the synthetic __model_obo__ cache row —
# identity-keyed on the owning alias, audience + scopes in the row's
# own columns — with zero IdP calls, and the row is named so
# deprovisioning can find it by prefix.
posts_before = client.posts
m2 = await mint_obo_access_token(
app_state=app_state,
user_id=USER,
alias="model-a",
audience=cfg["AUD_A"],
scopes=cfg.get("SCOPE_A", ""),
grant_leg="rfc8693",
)
cache_row = storage.get_mcp_user_token(USER, model_obo_cache_server("model-a"))
if m1:
record(
"VERIFIED"
if m2 and client.posts == posts_before and cache_row is not None
else "FAILED",
f"M2 model cache-hit: token={redact(m2)} kc_calls="
f"{client.posts - posts_before} (want 0) synthetic_row="
f"{'present' if cache_row is not None else 'MISSING'}",
)
else:
record("FAILED", "M2 model cache-hit: blocked behind M1 — M1 failed, see above")
# M3 — the mode/profile pairing refusal that replaced the pre-#955
# overload: an entra-leg mode on this rfc8693 deployment must yield
# None with ZERO IdP calls and record the grant_profile_mismatch
# cause the session heartbeat reads (under its own alias — a
# deployment defines the entra-mode variant as its own row).
posts_before = client.posts
m3 = await mint_obo_access_token(
app_state=app_state,
user_id=USER,
alias="model-a-entra",
audience=cfg["AUD_A"],
grant_leg="entra",
)
m3_cause = model_mint_refusal_cause(
"model_obo", model_obo_cause_key("model-a-entra", grant_leg="entra"), USER
)
record(
"VERIFIED"
if m3 is None and client.posts == posts_before and m3_cause == "grant_profile_mismatch"
else "FAILED",
f"M3 mode/profile mismatch refusal: token={redact(m3)} (want absent) "
f"kc_calls={client.posts - posts_before} (want 0) cause={m3_cause!r}",
)
finally:
await inner.aclose()
def main() -> int:
required = [
"KC_TOKEN_ENDPOINT",
"KC_ISSUER",
"KC_CLIENT_ID",
"KC_CLIENT_SECRET",
"KC_USER",
"KC_PASSWORD",
"AUD_A",
"AUD_B",
]
cfg = {k: os.environ[k] for k in os.environ if k.startswith(("KC_", "AUD_", "SCOPE_"))}
missing = [k for k in required if not cfg.get(k)]
if missing:
print(f"Missing env: {', '.join(missing)} — run via keycloak_e2e.sh")
return 2
print("Headless password login to Keycloak (the credential the feature captures)...")
refresh_token = _password_login(cfg)
asyncio.run(_run(cfg, refresh_token))
print("\n=== summary ===")
for status, msg in RESULTS:
print(f" {status:>8} {msg}")
return 0 if all(s in ("VERIFIED", "SKIPPED") for s, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# OSS-path (RFC 8693) end-to-end: spin up ephemeral Keycloak, configure the
# realm, run keycloak_e2e.py against the REAL Turnstone mint engine, tear down.
# Fully headless — no browser. Manual test tooling, not run in CI.
set -euo pipefail
cd "$(dirname "$0")/../.." # repo root (uv run needs it)
CONTAINER=kc-obo-e2e
PORT=8091
KC="docker exec $CONTAINER /opt/keycloak/bin/kcadm.sh"
cleanup() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; }
trap cleanup EXIT
cleanup
echo ">> starting Keycloak 26.3 (ephemeral)..."
docker run -d --name "$CONTAINER" -p "127.0.0.1:${PORT}:8080" \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.3 start-dev >/dev/null
echo ">> waiting for Keycloak (dev-mode boot can take a few minutes on a loaded host)..."
# Wait on kcadm auth succeeding directly — more reliable than the host HTTP port,
# and generous enough for a resource-starved boot (up to ~6 min).
ready=""
for _ in $(seq 1 90); do
if $KC config credentials --server http://localhost:8080 --realm master \
--user admin --password admin >/dev/null 2>&1; then
ready=1
break
fi
sleep 4
done
[ -n "$ready" ] || { echo "Keycloak did not become ready in time"; docker logs "$CONTAINER" 2>&1 | tail -15; exit 1; }
echo ">> configuring realm 'spike'..."
$KC create realms -s realm=spike -s enabled=true >/dev/null
# Confidential client with standard token exchange (the RFC 8693 leg) + direct
# access grant (headless password login to fetch the user's refresh token).
$KC create clients -r spike -s clientId=turnstone -s enabled=true -s publicClient=false \
-s secret=spike-secret -s directAccessGrantsEnabled=true \
-s 'attributes={"standard.token.exchange.enabled":"true"}' >/dev/null
for t in mcp-a mcp-b mcp-c; do
$KC create clients -r spike -s clientId=$t -s enabled=true -s publicClient=false -s secret=x >/dev/null
done
$KC create users -r spike -s username=e2e-user -s enabled=true -s email=e2e@spike.test \
-s emailVerified=true -s firstName=E2E -s lastName=User >/dev/null
$KC set-password -r spike --username e2e-user --new-password e2e-pw >/dev/null
TURNSTONE_UUID=$($KC get clients -r spike -q clientId=turnstone --fields id --format csv --noquotes)
# Audience client scopes for mcp-a and mcp-b ONLY (mcp-c stays unconsented → E6).
for t in mcp-a mcp-b; do
SID=$($KC create client-scopes -r spike -s name=aud-$t -s protocol=openid-connect -i)
$KC create "client-scopes/$SID/protocol-mappers/models" -r spike -s name=aud-$t \
-s protocol=openid-connect -s protocolMapper=oidc-audience-mapper \
-s "config={\"included.client.audience\":\"$t\",\"access.token.claim\":\"true\"}" >/dev/null
$KC update "clients/$TURNSTONE_UUID/optional-client-scopes/$SID" -r spike >/dev/null
done
echo ">> running the product e2e harness..."
export KC_TOKEN_ENDPOINT="http://127.0.0.1:${PORT}/realms/spike/protocol/openid-connect/token"
export KC_ISSUER="http://127.0.0.1:${PORT}/realms/spike"
export KC_CLIENT_ID=turnstone KC_CLIENT_SECRET=spike-secret
export KC_USER=e2e-user KC_PASSWORD=e2e-pw
export AUD_A=mcp-a SCOPE_A=aud-mcp-a AUD_B=mcp-b SCOPE_B=aud-mcp-b AUD_C=mcp-c
uv run python scripts/obo-e2e/keycloak_e2e.py
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+192 -30
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Server API",
"version": "1.7.0a2",
"version": "1.8.0a5",
"description": "Single-node workstream management, chat interaction, and real-time streaming."
},
"paths": {
@@ -228,6 +228,16 @@
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
@@ -390,6 +400,26 @@
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Error 503",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
@@ -609,7 +639,7 @@
"tags": [
"Streaming"
],
"description": "Server-Sent Events stream for node-level state broadcasts. Emits a node_snapshot event on connect (workstreams, health, aggregate), followed by real-time delta events (ws_state, ws_activity, ws_created, ws_closed, ws_rename, health_changed, aggregate). Pass ?expected_node_id=X for identity verification (returns 409 on mismatch).",
"description": "Server-Sent Events stream for node-level state broadcasts. Emits a node_snapshot event on connect (workstreams, health, aggregate), followed by real-time delta events (ws_state, ws_activity, ws_created, ws_closed, ws_rename, health_changed, aggregate). Pass ?expected_node_id=X for identity verification (returns 409 on mismatch). Every event's SSE id is an opaque '{boot_epoch}-{counter}' string; presenting it on reconnect (Last-Event-ID header or ?last_event_id=) replays missed events, or emits a replay_truncated event (reason: ring_evicted with lost_count + earliest_available_id, or boot_epoch when the cursor predates this server process) followed by a fresh node_snapshot. Treat the id as opaque \u2014 its format may change.",
"responses": {
"200": {
"description": "Success"
@@ -1443,6 +1473,27 @@
}
}
},
"/v1/api/personas": {
"get": {
"summary": "List enabled personas for the workstream-creation picker",
"operationId": "v1_api_personas_get",
"tags": [
"Personas"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListPersonaChoicesResponse"
}
}
}
}
}
}
},
"/v1/api/models": {
"get": {
"summary": "List available model aliases",
@@ -2239,16 +2290,23 @@
"SendResponse": {
"properties": {
"status": {
"description": "'ok', 'busy', 'queued', or 'queue_full'",
"description": "'ok' (fresh turn dispatched), 'queued' (folded into the live turn's interjection queue, or \u2014 when `deferred` is true \u2014 parked for dispatch after the current command window), 'queue_full', 'attachments_busy' (attachments can't ride a queued turn; retry when idle), or 'cross_user_interjection' (another participant's turn is in flight; carried on the 409 body).",
"examples": [
"ok",
"busy",
"queued",
"queue_full"
"queue_full",
"attachments_busy",
"cross_user_interjection"
],
"title": "Status",
"type": "string"
},
"deferred": {
"default": false,
"description": "Set on `queued` responses: the message is parked on the workstream's deferred-send list (a slash-command window holds the worker slot, or earlier deferred sends are still pending) and dispatches as an ordinary full-fidelity send afterwards \u2014 it is NOT in a live turn's interjection queue. `DELETE .../send` retracts it until dispatch. Node-local and in-memory: a node restart before dispatch drops it (at-most-once intake).",
"title": "Deferred",
"type": "boolean"
},
"attached_ids": {
"description": "Attachment ids actually attached to this turn. Subset of the request's `attachment_ids` (or the auto-consumed pending set). Empty when the send carries no attachments.",
"items": {
@@ -2425,6 +2483,12 @@
"title": "Skill",
"type": "string"
},
"persona": {
"default": "",
"description": "Persona name (slug) to create the workstream with. Resolved and snapshotted at creation \u2014 later persona edits never affect this workstream. Empty selects the kind's default persona; on a database with no personas seeded the workstream is created with legacy (unrestricted) behavior.",
"title": "Persona",
"type": "string"
},
"notify_targets": {
"anyOf": [
{
@@ -2537,6 +2601,23 @@
},
"title": "Attachment Ids",
"type": "array"
},
"initial_message_status": {
"anyOf": [
{
"enum": [
"queue_full",
"refused_closed"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Present ONLY when the workstream was created but its initial_message could not be delivered: 'queue_full' (a raced live worker's interjection queue was at capacity \u2014 resend via /send; any uploads stay staged) or 'refused_closed' (the workstream was closed mid-create). Absent whenever the message was dispatched.",
"title": "Initial Message Status"
}
},
"required": [
@@ -2665,21 +2746,17 @@
},
"pending_approval": {
"default": false,
"description": "True when the workstream is parked on ``_approval_event`` awaiting an operator approve/deny. Mirrors the same field on ``DashboardWorkstream`` / cluster live projections so a freshly-loaded chat tab can render the inline approval gate from the detail snapshot before SSE replay arrives.",
"description": "True when at least one approval cycle is live (a gate thread parked awaiting an operator approve/deny). Mirrors the same field on ``DashboardWorkstream`` / cluster live projections so a freshly-loaded chat tab can render the inline approval gate from the detail snapshot before SSE replay arrives.",
"title": "Pending Approval",
"type": "boolean"
},
"pending_approval_detail": {
"anyOf": [
{
"$ref": "#/components/schemas/PendingApprovalDetail"
},
{
"type": "null"
}
],
"default": null,
"description": "Inline approval payload \u2014 same shape as ``DashboardWorkstream.pending_approval_detail``. ``None`` when no approval is pending. Lets a reload paint the action row + judge verdicts immediately instead of relying on the SSE approve_request replay timing window."
"pending_approval_details": {
"description": "Inline approval payloads, one per live cycle, oldest first \u2014 same shape as ``DashboardWorkstream.pending_approval_details``. Empty when no approval is pending. Lets a reload paint every action row + judge verdicts immediately instead of relying on the SSE approve_request replay timing window. Replaces 1.6's ``pending_approval_detail`` single-object field (breaking, 1.7).",
"items": {
"$ref": "#/components/schemas/PendingApprovalDetail"
},
"title": "Pending Approval Details",
"type": "array"
}
},
"required": [
@@ -2692,8 +2769,14 @@
"type": "object"
},
"PendingApprovalDetail": {
"description": "Inline approval payload merged into ``DashboardWorkstream``.\n\nSet when a workstream's ``approve_tools`` is parked on\n``_approval_event``; ``None`` (omitted) otherwise. Cross-tenant\nexposure here follows the same trusted-team posture as\n``activity`` / ``tokens`` \u2014 see ``server.py``'s ``dashboard``\nhandler comment.",
"description": "Inline approval payload merged into ``DashboardWorkstream``.\n\nOne entry per live approval CYCLE \u2014 a gate thread parked in\n``approve_tools`` awaiting the operator. Parallel task agents run\nconcurrent gates, so a workstream can have several of these at\nonce (``pending_approval_details``, oldest first). Cross-tenant\nexposure here follows the same trusted-team posture as\n``activity`` / ``tokens`` \u2014 see ``server.py``'s ``dashboard``\nhandler comment.",
"properties": {
"cycle_id": {
"default": "",
"description": "Identity of this approval cycle. Echo it back on ``POST /v1/api/workstreams/{ws_id}/approve`` to resolve exactly this round \u2014 required for correctness when several cycles are live (parallel task agents).",
"title": "Cycle Id",
"type": "string"
},
"call_id": {
"default": "",
"description": "Primary call_id \u2014 first non-empty call_id in items list order. Matches the 409 ``current_call_id`` response from ``POST /v1/api/workstreams/{ws_id}/approve`` so the UI can render the same identifier the server reports as current.",
@@ -2718,7 +2801,7 @@
"type": "object"
},
"PendingApprovalItem": {
"description": "One pending tool-call inside a ``PendingApprovalDetail`` envelope.\n\nMirrors the dict ``SessionUIBase.serialize_pending_approval_detail``\nemits per item. ``heuristic_verdict`` / ``judge_verdict`` are kept\nloosely-typed because the underlying verdict shape varies by tier;\nconsumers that want the full structure can decode against\n:class:`turnstone.sdk.events.IntentVerdictEvent`.",
"description": "One pending tool-call inside a ``PendingApprovalDetail`` envelope.\n\nMirrors the dict ``SessionUIBase.serialize_pending_approval_details``\nemits per item inside each cycle entry. ``heuristic_verdict`` / ``judge_verdict`` are kept\nloosely-typed because the underlying verdict shape varies by tier;\nconsumers that want the full structure can decode against\n:class:`turnstone.sdk.events.IntentVerdictEvent`.",
"properties": {
"call_id": {
"default": "",
@@ -2977,17 +3060,13 @@
"default": null,
"title": "Project Id"
},
"pending_approval_detail": {
"anyOf": [
{
"$ref": "#/components/schemas/PendingApprovalDetail"
},
{
"type": "null"
}
],
"default": null,
"description": "Inline approval payload for the coordinator children-tree UI. Carries the merged ``_pending_approval`` items list + per-call_id LLM verdict cache so a coord can render approve/deny buttons + judge pill without a separate per-child round-trip. ``None`` when no approval is pending. Also surfaced (verbatim) on ``GET /v1/api/cluster/ws/live`` via the ``_CLUSTER_WS_LIVE_KEYS`` projection."
"pending_approval_details": {
"description": "Inline approval payload for the coordinator children-tree UI: EVERY live approval cycle, oldest first \u2014 parallel task agents gate concurrently, so a workstream can hold several prompts at once. Each entry carries the cycle's items + per-call_id LLM verdict cache so a coord can render approve/deny buttons + judge pill without a separate per-child round-trip; resolve each with its ``cycle_id``. Empty when no approval is pending. Also surfaced (verbatim) on ``GET /v1/api/cluster/ws/live`` via the ``_CLUSTER_WS_LIVE_KEYS`` projection. Replaces 1.6's ``pending_approval_detail`` single-object field (breaking, 1.7).",
"items": {
"$ref": "#/components/schemas/PendingApprovalDetail"
},
"title": "Pending Approval Details",
"type": "array"
},
"recent_auto_approvals": {
"description": "Per-ws ring buffer (cap 10) of recent tool calls that bypassed the operator approval gate. Surfaces ``WebUI._recent_auto_approvals`` so the coord-tree row can render an 'auto-approved by ...' pill when the child's skill / blanket / admin-policy rules silently let a tool through. Also projected onto ``GET /v1/api/cluster/ws/live`` via ``_CLUSTER_WS_LIVE_KEYS``.",
@@ -3150,6 +3229,30 @@
"default": 0.0,
"title": "Context Ratio",
"type": "number"
},
"project_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Project Id"
},
"persona": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Persona"
}
},
"required": [
@@ -3738,6 +3841,65 @@
"title": "ListSkillSummaryResponse",
"type": "object"
},
"PersonaChoice": {
"description": "Display fields for the creation picker \u2014 the persona's levers\n(prompt / tool set / toggles) deliberately stay server-side.",
"properties": {
"name": {
"description": "Persona slug, the value to pass as CreateWorkstreamRequest.persona",
"title": "Name",
"type": "string"
},
"display_name": {
"default": "",
"description": "Human-readable name",
"title": "Display Name",
"type": "string"
},
"description": {
"default": "",
"description": "What this persona is for",
"title": "Description",
"type": "string"
},
"applies_to_kinds": {
"description": "Workstream kinds this persona can be attached to",
"items": {
"type": "string"
},
"title": "Applies To Kinds",
"type": "array"
},
"is_default": {
"default": false,
"description": "Whether an empty persona field resolves to this one",
"title": "Is Default",
"type": "boolean"
}
},
"required": [
"name"
],
"title": "PersonaChoice",
"type": "object"
},
"ListPersonaChoicesResponse": {
"properties": {
"personas": {
"items": {
"$ref": "#/components/schemas/PersonaChoice"
},
"title": "Personas",
"type": "array"
},
"total": {
"default": 0,
"title": "Total",
"type": "integer"
}
},
"title": "ListPersonaChoicesResponse",
"type": "object"
},
"AvailableModelInfo": {
"properties": {
"alias": {
+581 -220
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,7 +32,7 @@
],
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^6.0.0",
"typescript": "^7.0.0",
"vitest": "^4.1"
}
}
+64
View File
@@ -75,15 +75,35 @@ export interface ToolInfoEvent {
items: Array<Record<string, unknown>>;
}
/** One approval CYCLE awaiting the operator. Several can be outstanding
* at once (parallel task agents each gate their own tool calls) key
* prompt UI by `cycle_id` and echo it back on the approve POST.
*
* `cycle_id` is optional because it was added in 1.7: a pre-1.7 server
* omits it on the wire, so a current SDK talking to an older node sees
* `undefined`. Resolve those the legacy way (no selector oldest
* cycle). A current server always sends it. */
export interface ApproveRequestEvent {
type: "approve_request";
cycle_id?: string;
items: Array<Record<string, unknown>>;
judge_pending?: boolean;
}
/** A specific approval cycle resolved; `cycle_id`/`call_ids` identify
* which prompt to dismiss.
*
* Both are optional for the same reason as `ApproveRequestEvent.cycle_id`
* a pre-1.7 server emits neither, so a bare "something resolved"
* dismisses the sole tracked prompt (the legacy fallback the UI and
* channel adapters keep). A current server always sends both. */
export interface ApprovalResolvedEvent {
type: "approval_resolved";
approved: boolean;
feedback: string;
always?: boolean;
cycle_id?: string;
call_ids?: string[];
}
export interface ToolResultEvent {
@@ -137,6 +157,49 @@ export interface CancelledEvent {
type: "cancelled";
}
/**
* Context-compaction lifecycle. `start` carries `trigger` ("manual"/"auto";
* auto adds `where` + `pct`); `progress` carries chunked-summarization
* `part`/`total`/`depth` (or `retry_in`/`error` for a retry wait); `end`
* carries `ok` plus either `before_tokens`/`after_tokens`/`summary` or the
* failure `reason`/`message`. The successful end's summary also replays from
* `/history` as a `role: "system"`, `source: "compaction"` entry.
*/
export interface CompactionEvent {
type: "compaction";
phase: "start" | "progress" | "end";
/** Correlates every event of one compaction run (0 from legacy emitters). */
compaction_id?: number;
/**
* End events only: true marks a force-abandoned compaction retiring
* after a successor generation took over skip failure notices for
* those (an OK end's result still stands; the history swap happened).
*/
superseded?: boolean;
/**
* Failed ends only: the emitter-computed display verdict show
* `message` only when true, instead of re-deriving suppression from
* reason/trigger/superseded client-side.
*/
notice?: boolean;
/** Present on start and on every end (ok or failed). */
trigger?: "manual" | "auto";
where?: string;
pct?: number;
part?: number;
total?: number;
depth?: number;
retry_in?: number;
error?: string;
warning?: string;
ok?: boolean;
reason?: string;
message?: string;
before_tokens?: number;
after_tokens?: number;
summary?: string;
}
// Global events
export interface WsStateEvent {
@@ -192,6 +255,7 @@ export type ServerEvent =
| BusyErrorEvent
| ClearUiEvent
| CancelledEvent
| CompactionEvent
| WsStateEvent
| WsActivityEvent
| WsRenameEvent
+9
View File
@@ -166,6 +166,13 @@ export class TurnstoneServer extends BaseClient {
approved?: boolean;
feedback?: string | null;
always?: boolean;
/** Resolve exactly this approval cycle (from ApproveRequestEvent.cycle_id).
* Omitting it resolves the OLDEST live cycle ambiguous when parallel
* task agents have several prompts outstanding, so pass it whenever the
* triggering event is known. */
cycleId?: string;
/** Alternative selector: any call_id inside the target cycle. */
callId?: string;
}): Promise<StatusResponse> {
return this.request(
"POST",
@@ -175,6 +182,8 @@ export class TurnstoneServer extends BaseClient {
approved: opts.approved ?? true,
feedback: opts.feedback,
always: opts.always,
cycle_id: opts.cycleId,
call_id: opts.callId,
},
},
);
+17
View File
@@ -130,6 +130,12 @@ export interface CreateWorkstreamRequest {
auto_approve?: boolean;
resume_ws?: string;
skill?: string;
/**
* Persona name (slug) to create the workstream with. Resolved and
* snapshotted at creation later persona edits never affect this
* workstream. Empty selects the kind's default persona.
*/
persona?: string;
/**
* Optional project to attach this workstream to. Drives the shared
* `project` memory scope; coordinator children inherit the parent's project.
@@ -158,6 +164,13 @@ export interface CreateWorkstreamResponse {
message_count?: number;
/** Ids of attachments saved by this request (multipart variant only). */
attachment_ids?: string[];
/**
* Present ONLY when the workstream was created but its initial_message
* could not be delivered: "queue_full" (raced live worker's interjection
* queue at capacity resend via /send; uploads stay staged) or
* "refused_closed" (workstream closed mid-create).
*/
initial_message_status?: "queue_full" | "refused_closed";
}
export interface CloseWorkstreamRequest {
@@ -256,6 +269,8 @@ export interface SavedWorkstreamInfo {
child_count?: number;
context_tokens?: number;
context_ratio?: number;
/** Persona slug the workstream was created with (empty/absent = pre-persona). */
persona?: string | null;
}
export interface ListSavedWorkstreamsResponse {
@@ -524,6 +539,8 @@ export interface ConsoleCreateWsRequest {
model?: string;
initial_message?: string;
skill?: string;
/** Persona slug — resolved and snapshotted at creation. */
persona?: string;
resume_ws?: string;
}
@@ -104,7 +104,6 @@ describe("TurnstoneServer attachments", () => {
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(JSON.parse(init.body)).toEqual({
message: "hi",
ws_id: "ws-X",
attachment_ids: ["a1", "a2"],
});
});
@@ -117,7 +116,7 @@ describe("TurnstoneServer attachments", () => {
});
await client.send("hi", "ws-X");
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(JSON.parse(init.body)).toEqual({ message: "hi", ws_id: "ws-X" });
expect(JSON.parse(init.body)).toEqual({ message: "hi" });
});
it("createWorkstream with attachments sends multipart and auto-generates ws_id", async () => {
+2 -2
View File
@@ -74,8 +74,8 @@ describe("TurnstoneServer", () => {
await client.send("Hello", "ws1");
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://test/v1/api/send");
expect(JSON.parse(init.body)).toEqual({ message: "Hello", ws_id: "ws1" });
expect(url).toBe("http://test/v1/api/workstreams/ws1/send");
expect(JSON.parse(init.body)).toEqual({ message: "Hello" });
});
it("injects auth header when token provided", async () => {
+1 -1
View File
@@ -74,7 +74,7 @@ class _FakeConfigStore:
def _fake_registry() -> MagicMock:
"""MagicMock whose ``.resolve()`` succeeds so the 503 gate passes."""
reg = MagicMock()
reg.resolve.return_value = (MagicMock(), "gpt-4", MagicMock())
reg.resolve.return_value = (MagicMock(), "gpt-4", MagicMock(), 0)
return reg
+29 -1
View File
@@ -3,9 +3,37 @@ not fixtures, and several test files want to import them directly."""
from __future__ import annotations
from typing import Any
import time
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock
if TYPE_CHECKING:
from collections.abc import Callable
def wait_until(cond: Callable[[], bool], timeout: float = 5.0) -> None:
"""Poll ``cond`` to True within ``timeout`` or fail the test.
The worker/wake tests can't join threads by identity:
``session_worker.send`` assigns ``ws.worker_thread`` under the lock
BEFORE ``t.start()``, so the instant a dispatching call returns, a
fast worker may already have run its exit backstop and installed the
(not-yet-started) wake thread joining whatever ``ws.worker_thread``
points at races ``RuntimeError: cannot join thread before it is
started``. Poll outcomes instead.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if cond():
return
time.sleep(0.005)
if cond():
# Final re-check: the condition can become true during the last
# sleep (or a CI descheduling stall past the deadline) — failing
# without re-looking makes the helper itself a flake source.
return
raise AssertionError("condition not met within timeout")
def make_chat_session(**overrides: Any) -> Any:
"""Build a minimal ``ChatSession`` with sane test defaults.
+45
View File
@@ -0,0 +1,45 @@
"""Shared helpers for the Python-driven node harnesses that evaluate the
``shared_static`` ES modules with script semantics."""
from __future__ import annotations
import re
import shutil
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pathlib import Path
def has_node() -> bool:
return shutil.which("node") is not None
# Module-level ``pytestmark = node_skip`` in each harness suite — the node
# detection lives here once, so a future change (version floor, env
# override) cannot land in one suite and silently miss another.
node_skip = pytest.mark.skipif(not has_node(), reason="node not available")
def demodulize(path: Path) -> str:
"""Strip ES-module syntax so ``vm.runInThisContext`` (script semantics)
can evaluate the file: imports drop (the harness loads the whole
dependency set into one shared context, so cross-file bindings resolve
as context globals, exactly like the pre-module classic scripts), and
``export`` keywords peel off their declarations.
Single-sourced here for every JS harness: a new module syntax form
(``export default``, re-exports) must be handled once, not per suite
a divergence between per-file copies surfaces as a confusing
``vm.runInThisContext`` SyntaxError in whichever suite lagged.
"""
src = path.read_text(encoding="utf-8")
src = re.sub(r"^import\s+\{[\s\S]*?\}\s+from\s+\"[^\"]+\";\s*$", "", src, flags=re.M)
src = re.sub(r"^import\s+[^;\n]+;\s*$", "", src, flags=re.M)
src = re.sub(
r"^export\s+(?=(?:async\s+)?(?:function|const|let|var|class)\b)", "", src, flags=re.M
)
src = re.sub(r"^export\s*\{[^}]*\};\s*$", "", src, flags=re.M)
return src
+57
View File
@@ -0,0 +1,57 @@
"""Shared OIDC posture builder for the model-auth / OBO test surface.
One construction site for the posture the mint and write-validator suites
read, built as a REAL (frozen) ``OIDCConfig`` so an override for a field
the dataclass does not carry raises at the call site. Named with a leading
underscore so pytest does not collect it.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
from turnstone.core.oidc import OIDCConfig
if TYPE_CHECKING:
from collections.abc import Iterator
# The issuer / token-endpoint pair the mint suites route their mock
# transports on.
ISSUER = "https://idp.test"
TOKEN_ENDPOINT = "https://idp.test/token"
def make_oidc_config(**overrides: Any) -> OIDCConfig:
"""A full, mintable OIDC posture; tests override the field under test,
everything else rides the dataclass defaults."""
defaults: dict[str, Any] = {
"enabled": True,
"issuer": ISSUER,
"client_id": "cid",
"client_secret": "csecret",
"token_endpoint": TOKEN_ENDPOINT,
}
defaults.update(overrides)
return OIDCConfig(**defaults)
def keyed_app_state() -> SimpleNamespace:
"""App-state stub satisfying ``ModelRegistry.reload``'s dynamic-auth key
guard, for suites exercising reload mechanics rather than key policy."""
return SimpleNamespace(mcp_token_store=object())
def mint_warn_state_reset() -> Iterator[None]:
"""Reset generator behind the mint suites' autouse fixtures: empties the
process-global mint warn/dedup/cause state before AND after each test,
so warn-dedup assertions are not order-dependent. Modules install it as
``yield from mint_warn_state_reset()`` in an autouse fixture.
"""
# Lazy import: non-mint consumers of this helper module (the write-
# validator suites) shouldn't pay the mcp_oauth import.
from turnstone.core.mcp_oauth import reset_model_mint_warn_state_for_tests
reset_model_mint_warn_state_for_tests()
yield
reset_model_mint_warn_state_for_tests()
+230
View File
@@ -0,0 +1,230 @@
"""#832 replay-parity harness: scenario table + runner.
The audit is controller determinism: with the plant's chunk sequence held
fixed, the streaming phase must produce an identical UI event sequence
and an identical committed message modulo the RULED behavior changes
restated in full on the transforms in ``test_832_parity.py``. This
module is the shared half: the scenario scripts (one row per
chunk-fieldUI translation the consumer performs) and the runner that
drives one through the streaming seam, recording everything the turn
observably produced.
Baselines are captured from the PRE-FOLD path (``UPDATE_832_PARITY=1``,
run at a tree where ``session.py`` is byte-identical to pre-fold main)
into ``tests/data/parity_832/``. The runner adapts to EITHER world by
signature, so a recapture at an old tree records real old-world
behavior, and capture mode refuses to write a record whose failure is
the harness's own call shape. Assert mode replays the same scripts
through the current tree and compares against the baseline, applying the
ruled transforms; a mismatch outside a ruled transform is a regression.
The provider fake arms ``cancel_ref`` EAGERLY (a closeable sentinel
appended inside ``create_streaming``, before the iterator is returned),
mirroring every real adapter: the wrapper classifies
creation-vs-midstream failures by that arming, so a fake that skipped it
would exercise only the creation arm.
"""
from __future__ import annotations
import inspect
import json
import os
import re
from pathlib import Path
from typing import Any
from tests._session_helpers import RecordingUI, make_session, scripted_provider
from turnstone.core.providers._protocol import StreamChunk, ToolCallDelta, UsageInfo
from turnstone.core.trajectory import Turn
FIXTURE_DIR = Path(__file__).parent / "data" / "parity_832"
UPDATE = os.environ.get("UPDATE_832_PARITY") == "1"
def _tc(index: int, call_id: str, name: str = "", args: str = "") -> ToolCallDelta:
return ToolCallDelta(index=index, id=call_id, name=name, arguments_delta=args)
_USAGE_A = UsageInfo(prompt_tokens=11, completion_tokens=0, total_tokens=11)
_USAGE_B = UsageInfo(prompt_tokens=11, completion_tokens=7, total_tokens=18)
# Scenario table — the V11 grid, one script per row. Scripts are chunk
# LISTS; the runner re-iterates a fresh iterator per attempt.
SCENARIOS: dict[str, list[StreamChunk]] = {
"content_only": [
StreamChunk(content_delta="Hello "),
StreamChunk(content_delta="world."),
StreamChunk(finish_reason="stop", usage=_USAGE_B),
],
"reasoning_then_content": [
StreamChunk(reasoning_delta="think a", usage=_USAGE_A),
StreamChunk(reasoning_delta=" think b"),
StreamChunk(content_delta="Answer."),
StreamChunk(finish_reason="stop", usage=_USAGE_B),
],
"tools_simple": [
StreamChunk(content_delta="Calling."),
StreamChunk(tool_call_deltas=[_tc(0, "call_1", "get_weather", '{"city": ')]),
StreamChunk(tool_call_deltas=[_tc(0, "", "", '"Paris"}')]),
StreamChunk(finish_reason="tool_calls", usage=_USAGE_B),
],
"combined_content_tools_finish": [
StreamChunk(content_delta="Before "),
StreamChunk(
content_delta="tools",
tool_call_deltas=[_tc(0, "call_1", "get_weather", '{"city": "Nice"}')],
finish_reason="tool_calls",
),
StreamChunk(usage=_USAGE_B),
],
"info_prefinish": [
StreamChunk(info_delta="[Searching: pinniped taxonomy]"),
StreamChunk(content_delta="Seals are pinnipeds."),
StreamChunk(finish_reason="stop", usage=_USAGE_B),
],
"info_postfinish_footer": [
StreamChunk(content_delta="Answer with sources."),
StreamChunk(finish_reason="stop", usage=_USAGE_B),
StreamChunk(info_delta="Sources:\n- example.com/page"),
],
"think_tags_split_across_chunks": [
StreamChunk(content_delta="<thi"),
StreamChunk(content_delta="nk>plan</think>\n\nAnswer"),
StreamChunk(finish_reason="stop", usage=_USAGE_B),
],
"blank_id_tools": [
StreamChunk(tool_call_deltas=[_tc(0, "", "get_weather", '{"city": "Oslo"}')]),
StreamChunk(finish_reason="tool_calls", usage=_USAGE_B),
],
"length_with_tools": [
StreamChunk(content_delta="Partial answer"),
StreamChunk(tool_call_deltas=[_tc(0, "call_1", "get_weather", '{"city": "Par')]),
StreamChunk(finish_reason="length", usage=_USAGE_B),
],
"content_filter": [
StreamChunk(content_delta="Redac"),
StreamChunk(finish_reason="content_filter", usage=_USAGE_B),
],
"no_finish_clean_exhaust": [
StreamChunk(content_delta="Half an ans"),
StreamChunk(usage=_USAGE_A),
],
"finish_only_no_content": [
StreamChunk(finish_reason="stop", usage=_USAGE_B),
],
"provider_blocks_on_terminal": [
StreamChunk(content_delta="Blocked."),
StreamChunk(
finish_reason="stop",
usage=_USAGE_B,
provider_blocks=[{"type": "reasoning_text", "text": "captured"}],
),
],
}
_SYNTH_ID = re.compile(r"^call_[0-9a-f]{32}$")
def _mask_synth_ids(record: dict[str, Any]) -> dict[str, Any]:
"""Replace uuid-backfilled tool-call ids with stable placeholders.
The blank-id repair mints ``call_<uuid4hex>`` per run real
nondeterminism inside the seam, but not behavior: mask ONLY that exact
shape (never a scripted provider id) with an index-stable token so
captures compare across runs. Applied to the committed projection;
UI events never carry call ids in this harness.
"""
result = record.get("result")
if not result:
return record
for i, tc in enumerate(result.get("tool_calls") or []):
if _SYNTH_ID.match(tc.get("id", "")):
tc["id"] = f"synth-id-{i}"
for i, block in enumerate(result.get("provider_content") or []):
if isinstance(block, dict) and _SYNTH_ID.match(str(block.get("id", ""))):
block["id"] = f"synth-id-{i}"
return record
def run_scenario(name: str) -> dict[str, Any]:
"""Drive one scenario through the streaming seam; return the record.
The record is everything the streaming phase observably produced: the
ordered UI events, the committed-message projection, the mid-stream
usage slot, and the exception class if the seam raised. Deliberately
seam-level at ``_stream_response`` full ``send()`` scenarios ride
the ported ladder suites instead.
Signature-adaptive so ``UPDATE_832_PARITY=1`` at a PRE-fold tree
records real old-world behavior: the pre-fold seam was
``_stream_response(msgs, my_generation) -> dict``, the post-fold one
is ``_stream_response(my_generation) -> ModelTurnResult`` (wire
prepared inside). A harness-shape failure must never be recorded as
behavior ``write_fixture`` refuses one.
"""
ui = RecordingUI()
session = make_session(ui=ui)
# Zero the ladder backoff: a scenario that reaches the mid-stream
# re-issue ladder (no_finish_clean_exhaust) must not sleep real
# exponential delays in a unit run. The retry-notice transform in
# test_832_parity hardcodes the matching "0s" wording.
session._RETRY_BASE_DELAY = 0
session._provider = scripted_provider(SCENARIOS[name])
pre_fold = "msgs" in inspect.signature(type(session)._stream_response).parameters
record: dict[str, Any] = {"scenario": name}
try:
if pre_fold:
# Splatted: the pre-fold seam took (msgs, my_generation), and a
# literal two-argument call reads as an arity error against the
# signature this tree actually has.
pre_fold_args: tuple[Any, ...] = ([{"role": "user", "content": "hi"}], 0)
msg = session._stream_response(*pre_fold_args)
msg.pop("_wire_msgs", None)
record["result"] = {
"content": msg.get("content", ""),
"tool_calls": msg.get("tool_calls"),
"provider_content": msg.get("_provider_content"),
}
else:
session.messages.append(Turn.user("hi"))
result = session._stream_response(0)
record["result"] = {
"content": result.content,
"tool_calls": result.tool_calls or None,
"provider_content": (
[dict(b) for b in result.turn.native.blocks] if result.turn.native else None
),
}
record["raised"] = None
except BaseException as exc: # noqa: BLE001 — the record IS the observation
record["result"] = None
record["raised"] = type(exc).__name__
record["ui_events"] = [[k, d] for k, d in ui.events]
record["last_usage"] = session._last_usage
record["cancelled_partial"] = session._cancelled_partial_msg
return _mask_synth_ids(record)
def fixture_path(name: str) -> Path:
return FIXTURE_DIR / f"{name}.json"
def load_fixture(name: str) -> dict[str, Any]:
return json.loads(fixture_path(name).read_text())
def write_fixture(name: str, record: dict[str, Any]) -> None:
# A TypeError before ANY UI event is the harness's own call-shape
# failure (run_scenario's signature adapter no longer matches this
# tree's seam), not old-world behavior — refuse to destroy the
# baseline with it.
if record.get("raised") == "TypeError" and not record.get("ui_events"):
raise AssertionError(
f"parity capture for {name!r} died calling the seam (TypeError before "
f"any UI event) — fix run_scenario's signature adapter; do not record"
)
FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
fixture_path(name).write_text(json.dumps(record, indent=2, sort_keys=True) + "\n")
+43
View File
@@ -0,0 +1,43 @@
"""Shared process/polling helpers for the bash + background-shell suites.
One copy instead of three: ``test_bash_tool_background_hang``,
``test_background_shells`` and ``test_bash_background_tool`` all assert on
process liveness and poll for asynchronous state. Leading underscore so
pytest doesn't collect it.
"""
from __future__ import annotations
import contextlib
import os
import signal
import time
def pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def kill_pid(pid: int) -> None:
with contextlib.suppress(OSError):
os.kill(pid, signal.SIGKILL)
def poll_until(predicate, timeout=10.0, interval=0.05):
"""Poll ``predicate`` until truthy or ``timeout``; RETURNS the last value
(falsy on timeout assert at the call site). Deliberately named apart
from ``tests/_helpers.wait_until``, which RAISES on timeout: two
same-named helpers with opposite failure semantics invite silently-green
tests."""
deadline = time.monotonic() + timeout
value = predicate()
while not value and time.monotonic() < deadline:
time.sleep(interval)
value = predicate()
return value
+171
View File
@@ -0,0 +1,171 @@
"""Inline-reasoning dialect conformance catalog.
Passthrough servers (parserless vLLM/llama.cpp, LM Studio, bare gateways)
emit model reasoning inline as ``<think>``/``<reasoning>`` blocks inside the
content stream a *dialect* of model output. This module is that dialect's
executable specification for ``split_inline_reasoning``: each case maps an
utterance to the exact ``(content, reasoning)`` lanes the one-shot must
produce.
Consumers: the one-shot conformance and one-shotstreaming property suites
in tests/test_think_tag_split.py. Lane suites (session, judge,
output-guard, optimizer, drain-stream) pin their lanes with suite-local
utterances through their own fakes adding a case HERE extends the
semantics spec, not automatically any lane suite.
The split is RAW (residue whitespace stays; ``drain_stream`` owns the one
trim over its joined runs). ``passthrough`` marks cases the split must
return BYTE-IDENTICAL: tag-free text, and text whose only tags are orphan
CLOSE tags. The latter is a
review ruling, not an accident: a close tag whose open never arrived is
indistinguishable from prose QUOTING the tag, and drained lanes routinely
quote third-party text (web-fetch answers citing pages about reasoning
models, guard verdicts echoing judged content) any reclassification
would let quoted text destroy real results. Display lanes wanting
stricter cosmetic peeling (the title) own that locally as formatting.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class DialectCase:
id: str
utterance: str
content: str
reasoning: str
# The split returns the utterance byte-identical (no tag consumed):
# tag-free text, or orphan-close-only text (quoted-tag safety).
passthrough: bool = False
CASES: tuple[DialectCase, ...] = (
DialectCase(
id="no_tag_byte_identity",
utterance="Just an answer.",
content="Just an answer.",
reasoning="",
passthrough=True,
),
DialectCase(
# The fast path must not strip: unconsumed content is
# byte-identical, whitespace included.
id="no_tag_preserves_whitespace",
utterance=" spaced \n",
content=" spaced \n",
reasoning="",
passthrough=True,
),
DialectCase(
id="leading_block",
utterance="<think>plan</think>Answer",
content="Answer",
reasoning="plan",
),
DialectCase(
# The split is RAW — tag residue stays; drain_stream owns the ONE
# blank-edge-line trim over its joined runs (pinned there), which
# preserves the code block's first-line indentation.
id="indented_code_block_raw_residue",
utterance="<think>plan</think>\n\n print(1)\n more()",
content="\n\n print(1)\n more()",
reasoning="plan",
),
DialectCase(
id="leading_block_raw_residue",
utterance="<think>plan</think>\n\nAnswer\n",
content="\n\nAnswer\n",
reasoning="plan",
),
DialectCase(
id="interleaved_blocks_both_vocabularies",
utterance="Intro <think>a</think>mid <reasoning>b</reasoning>end",
content="Intro mid end",
reasoning="ab",
),
DialectCase(
id="unterminated_open_tail_is_reasoning",
utterance="Answer part<think>never closed",
content="Answer part",
reasoning="never closed",
),
DialectCase(
# QUOTED-CLOSE SAFETY (review ruling): an orphan close is
# indistinguishable from a quoted tag — everything passes through.
# A malicious page embedding the literal string must not be able
# to wipe the extraction that quotes it.
id="orphan_close_passes_through",
utterance="The page says templates emit </think> after the preamble. Answer: 42.",
content="The page says templates emit </think> after the preamble. Answer: 42.",
reasoning="",
passthrough=True,
),
DialectCase(
# Template-pre-injected shape ("reasoning</think>answer"): the seam
# deliberately passes it through — segregating it would require
# treating every quoted close as a boundary. Post-#831 every lane
# streams, and known streaming surfaces strip the orphan close
# server-side; display lanes peel cosmetically on their own.
id="preinject_shape_passes_through",
utterance="plan text</think>\n\nAnswer",
content="plan text</think>\n\nAnswer",
reasoning="",
passthrough=True,
),
DialectCase(
id="immediate_close_passes_through",
utterance="</think>Answer",
content="</think>Answer",
reasoning="",
passthrough=True,
),
DialectCase(
# Any close tag closes any open block (splitter semantics; the old
# pairwise per-caller strip treated this as unterminated).
id="cross_vocabulary_close",
utterance="<think>x</reasoning>Answer",
content="Answer",
reasoning="x",
),
DialectCase(
id="think_only",
utterance="<think>all reasoning</think>",
content="",
reasoning="all reasoning",
),
DialectCase(
id="think_only_unterminated",
utterance="<think>everything",
content="",
reasoning="everything",
),
DialectCase(
# A balanced block followed by a stray close: the block is
# consumed, the stray close stays in content (quoted-tag safety),
# and the consumed-tag strip applies.
id="balanced_block_then_stray_close",
utterance="<think>a</think>b</think>c",
content="b</think>c",
reasoning="a",
),
DialectCase(
id="multiple_blocks_accumulate",
utterance="<think>one</think>mid<think>two</think>tail",
content="midtail",
reasoning="onetwo",
),
DialectCase(
# ACCEPTED RESIDUAL (R2): the split is content-blind, so a literal
# OPEN tag in legitimate prose misroutes the remainder — the same
# false positive the interactive splitter has carried in the
# field. This pin makes any future fix a conscious change.
# Scope note: R2 applies only where the scan runs — a backend
# declaring ``server_parses_reasoning`` turns the scan off and
# this utterance passes through byte-identical (pinned in
# test_scan_tags_off_returns_every_utterance_byte_identical).
id="literal_open_tag_false_positive_r2",
utterance="The `<think>` tag opens a block.",
content="The `",
reasoning="` tag opens a block.",
),
)
+6
View File
@@ -51,6 +51,12 @@ def make_replay_mocks(
ui._ws_messages = 0
for key, value in ui_overrides.items():
setattr(ui, key, value)
# Both replay paths read cycle cards via ``pending_approval_cards()``
# (one card per concurrent approval cycle). Model it from the
# single-slot ``_pending_approval`` override so tests keep seeding
# the one field; a bare MagicMock here would iterate empty and
# silently drop the approve_request from the replay.
ui.pending_approval_cards = lambda: [ui._pending_approval] if ui._pending_approval else []
ws = MagicMock()
ws.session = session
request = MagicMock()
+563 -7
View File
@@ -1,12 +1,13 @@
"""Shared session-test helpers.
Two reasoning-test modules (``test_session_replay_reasoning.py`` and
``test_session_synth_reasoning_block.py``) need the same minimal
``ChatSession`` factory + a ``SessionUIBase`` no-op subclass. Hoisting
keeps a future third caller from drifting on the defaults the third
existing ``_make_session`` (``test_model_registry.py``) deliberately
takes a different signature (registry / model_alias / reasoning_effort
+ ``_FakeUI``) and is NOT a candidate for sharing this helper.
The minimal ``ChatSession`` factory, the ``SessionUIBase`` no-op/recording
subclasses, and the tree's standard streaming provider fakes
(``make_result`` / ``arm_session`` / ``scripted_provider`` /
``ArmedHandle``, at the bottom): every suite driving the streaming seam
imports them from here, so the eager-arming contract lives in one place.
The one deliberate exception, ``test_model_registry.py``'s
``_make_session``, takes a different signature (registry / model_alias /
reasoning_effort + ``_FakeUI``) and is NOT a candidate for sharing.
Module is named with a leading underscore so pytest doesn't try to
collect it as a test file it's an importable utility, not a test.
@@ -14,11 +15,16 @@ collect it as a test file — it's an importable utility, not a test.
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
from turnstone.core.model_turn import ModelTurnResult
from turnstone.core.providers import ModelCapabilities, StreamChunk, ToolCallDelta, UsageInfo
from turnstone.core.session import ChatSession
from turnstone.core.session_ui_base import SessionUIBase
from turnstone.core.trajectory import ProviderNative, ToolCall, Turn
class NullUI(SessionUIBase):
@@ -43,3 +49,553 @@ def make_session(**kwargs: Any) -> ChatSession:
}
defaults.update(kwargs)
return ChatSession(**defaults)
def mock_completion_result(
content: str = "",
tool_calls: list[dict[str, Any]] | None = None,
) -> MagicMock:
"""A provider result shaped like ``CompletionResult``.
Callers that route through ``model_turn`` (judges, task agents, and
every lane #827 migrates) hit its re-ingest, which iterates
``tool_calls``/``provider_blocks`` and joins ``reasoning`` a bare
MagicMock attribute would TypeError deep inside the seam, so every
field the re-ingest reads is pinned to a real value here. ONE shared
definition: when the re-ingest starts reading a new CompletionResult
field, add it here and every suite moves together.
"""
result = MagicMock()
result.content = content
result.tool_calls = tool_calls
result.finish_reason = "stop"
result.usage = None
result.provider_blocks = []
result.reasoning = ""
return result
def fake_chat_stream(
*,
content: str | None = None,
tool_calls: list[dict[str, str]] | None = None,
finish_reason: str = "stop",
prompt_tokens: int = 10,
completion_tokens: int = 5,
reasoning_content: str | None = None,
reasoning: str | None = None,
) -> list[Any]:
"""Fake OpenAI Chat Completions SSE chunks for driving the REAL
``OpenAIChatCompletionsProvider`` through a fake SDK client::
client.chat.completions.create = lambda **kw: fake_chat_stream(...)
Exercises the adapter's ``_iter_stream`` plus ``drain_stream`` end to
end (the highest-fidelity fake lane), unlike ``as_stream`` which fakes
at the provider boundary. ``tool_calls`` entries are
``{"id", "name", "arguments"}`` dicts. ``SimpleNamespace`` (not
``MagicMock``) so absent SDK fields read as real ``None`` an
auto-created mock attribute would leak into ``len()``/string paths.
Emits the realistic three-phase shape: data chunk(s), a finish-reason
chunk, then the ``stream_options.include_usage`` usage-only chunk with
empty ``choices``.
"""
def _delta(
content_val: str | None = None,
tcs: list[Any] | None = None,
rc: str | None = None,
rsn: str | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
content=content_val,
tool_calls=tcs,
reasoning=rsn,
reasoning_content=rc,
annotations=None,
)
chunks: list[Any] = []
if reasoning_content is not None or reasoning is not None:
chunks.append(
SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason=None, delta=_delta(rc=reasoning_content, rsn=reasoning)
)
],
usage=None,
)
)
if content is not None:
chunks.append(
SimpleNamespace(
choices=[SimpleNamespace(finish_reason=None, delta=_delta(content))],
usage=None,
)
)
if tool_calls:
tcs = [
SimpleNamespace(
index=i,
id=tc.get("id", ""),
function=SimpleNamespace(
name=tc.get("name", ""), arguments=tc.get("arguments", "")
),
)
for i, tc in enumerate(tool_calls)
]
chunks.append(
SimpleNamespace(
choices=[SimpleNamespace(finish_reason=None, delta=_delta(None, tcs))],
usage=None,
)
)
chunks.append(
SimpleNamespace(
choices=[SimpleNamespace(finish_reason=finish_reason, delta=_delta())],
usage=None,
)
)
chunks.append(
SimpleNamespace(
choices=[],
usage=SimpleNamespace(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
prompt_tokens_details=None,
input_tokens_details=None,
),
)
)
return chunks
class _ScriptedClient:
"""Callable client-method fake following a script of stream builders.
Call N returns the stream described by ``scripts[N]``; the last script
repeats for any further calls. Each script is a dict of kwargs for
the bound stream builder, or a pre-built return value. Records every
call's kwargs on ``.calls`` — read ``len(fn.calls)`` where a test
previously kept its own counter cell, and ``fn.calls[i]["messages"]``
where it captured request bodies.
"""
def __init__(self, scripts: tuple[Any, ...], to_stream: Any) -> None:
self._scripts = scripts
self._to_stream = to_stream
self.calls: list[dict[str, Any]] = []
def __call__(self, **kwargs: Any) -> Any:
self.calls.append(kwargs)
script = self._scripts[min(len(self.calls) - 1, len(self._scripts) - 1)]
return self._to_stream(**script) if isinstance(script, dict) else script
def scripted_chat_client(*scripts: Any) -> _ScriptedClient:
"""A scripted ``client.chat.completions.create`` — dict scripts are
:func:`fake_chat_stream` kwargs."""
return _ScriptedClient(scripts, fake_chat_stream)
def scripted_anthropic_client(*scripts: Any) -> _ScriptedClient:
"""A scripted ``client.messages.stream`` — dict scripts are
:func:`fake_anthropic_stream` kwargs (``blocks`` plus optional
``stop_reason``/``usage``)."""
return _ScriptedClient(scripts, fake_anthropic_stream)
class FakeAnthropicBlock:
"""A full-content Anthropic content-block fake for
:func:`fake_anthropic_stream` plain attributes plus the
``model_dump()`` the provider's block capture reads."""
def __init__(self, **fields: Any) -> None:
self._fields = fields
for key, value in fields.items():
setattr(self, key, value)
def model_dump(self, **_kw: Any) -> dict[str, Any]:
return dict(self._fields)
def fake_anthropic_stream(
blocks: list[Any],
*,
stop_reason: str | None = "end_turn",
usage: Any = None,
) -> Any:
"""Fake Anthropic SDK stream context manager for tests that drive the
REAL ``AnthropicProvider`` through a fake client::
client.messages.stream = lambda **kw: fake_anthropic_stream(...)
Accepts the same full-content block fakes the pre-#831
``get_final_message`` fixtures used (objects with ``.type`` + fields
and ``model_dump()``) and synthesizes the real event grammar the
streaming iterator consumes: ``content_block_start`` carries the block
with its text/thinking/signature EMPTIED and ``input`` as ``{}`` (the
SDK start shape), deltas carry the content, ``content_block_stop``
finalizes tool input, and the closing ``message_delta`` carries
``stop_reason`` (+ optional usage object). Without the stripping, the
provider's raw-block accumulator would double every text/thinking
field (start capture + delta append).
``stop_reason=None`` omits the closing ``message_delta`` entirely
the terminal-signal-less lax-gateway shape ``finish_reason_optional``
exists for (content arrives, then the stream just ends).
"""
events: list[Any] = []
for idx, block in enumerate(blocks):
d = dict(block.model_dump()) if hasattr(block, "model_dump") else dict(vars(block))
btype = d.get("type", "")
start = dict(d)
if btype == "text":
start["text"] = ""
elif btype == "thinking":
start["thinking"] = ""
start["signature"] = ""
elif btype == "tool_use":
start["input"] = {}
events.append(
SimpleNamespace(
type="content_block_start", index=idx, content_block=SimpleNamespace(**start)
)
)
if btype == "text" and d.get("text"):
events.append(
SimpleNamespace(
type="content_block_delta",
index=idx,
delta=SimpleNamespace(type="text_delta", text=d["text"]),
)
)
elif btype == "thinking":
if d.get("thinking"):
events.append(
SimpleNamespace(
type="content_block_delta",
index=idx,
delta=SimpleNamespace(type="thinking_delta", thinking=d["thinking"]),
)
)
if d.get("signature"):
events.append(
SimpleNamespace(
type="content_block_delta",
index=idx,
delta=SimpleNamespace(type="signature_delta", signature=d["signature"]),
)
)
elif btype == "tool_use":
events.append(
SimpleNamespace(
type="content_block_delta",
index=idx,
delta=SimpleNamespace(
type="input_json_delta",
partial_json=json.dumps(d.get("input", {})),
),
)
)
events.append(SimpleNamespace(type="content_block_stop", index=idx))
if stop_reason is not None or usage is not None:
events.append(
SimpleNamespace(
type="message_delta", usage=usage, delta=SimpleNamespace(stop_reason=stop_reason)
)
)
mgr = MagicMock()
mgr.__enter__ = MagicMock(return_value=events)
mgr.__exit__ = MagicMock(return_value=False)
return mgr
def as_stream(result: Any) -> list[StreamChunk]:
"""Adapt a ``CompletionResult``-shaped fake to a ``create_streaming``
return value (single terminal chunk).
The #831 transport collapse routes every single-shot lane through
``drain_stream(provider.create_streaming(...))``, so provider fakes
return chunk iterables now. Tests keep building result-shaped fakes
(``mock_completion_result`` or hand-rolled) and wrap them at
assignment: ``provider.create_streaming.return_value =
as_stream(result)``. A list re-iterates on every call, so one
``return_value`` serves repeated-call tests; convert AFTER mutating
the fake's fields — the chunk snapshots them.
Multi-chunk accumulation semantics are exercised by the dedicated
``drain_stream`` unit tests, not through this helper.
"""
deltas = [
ToolCallDelta(
index=i,
id=tc.get("id", ""),
name=tc.get("function", {}).get("name", ""),
arguments_delta=tc.get("function", {}).get("arguments", ""),
)
for i, tc in enumerate(result.tool_calls or [])
]
return [
StreamChunk(
content_delta=result.content or "",
reasoning_delta=getattr(result, "reasoning", "") or "",
tool_call_deltas=deltas,
usage=result.usage,
finish_reason=result.finish_reason or "stop",
provider_blocks=list(result.provider_blocks or []),
)
]
def think_tag_stream(utterance: str) -> list[StreamChunk]:
"""``create_streaming`` return value simulating a passthrough server
that emits *utterance* typically think-tag-bearing as plain
streamed content.
The per-lane fixture for inline-reasoning dialect pins: lane tests
supply their own utterances (the dialect's SEMANTICS are specified
once, in ``tests._reasoning_dialect.CASES``, and pinned by the
one-shot suites lane pins assert lane behavior, not tag grammar).
Routes through the real ``drain_stream`` seam exactly like
``as_stream``.
"""
return as_stream(mock_completion_result(content=utterance))
def seam_provider(utterance: str, *, provider_name: str = "openai-compatible") -> MagicMock:
"""Provider fake whose ``create_streaming`` replays *utterance* through
the REAL drain seam (``think_tag_stream``) THE lane-suite seam fake.
One definition so the lane suites cannot drift when the provider
surface ``model_turn`` probes grows: real ``ModelCapabilities`` for
the clamp math, ``provider_name`` overridable per suite.
Assign the RETURNED fake to ``session._provider`` never mutate the
provider a session resolved on its own: with a MagicMock client the
session resolves the process-wide ``create_provider(...)`` singleton,
and writing that shared instance's ``create_streaming`` poisons every
later session in the test run (the SSE-recovery e2e servers resolve
the same instance).
"""
provider = provider_shell(provider_name)
provider.create_streaming = MagicMock(return_value=think_tag_stream(utterance))
return provider
class RecordingUI:
"""UI adapter recording the ordered event stream ``send()`` emits."""
def __init__(self):
self.events = []
def _rec(self, kind, detail=""):
self.events.append((kind, detail))
def on_turn_start(self):
self._rec("turn_start")
def on_turn_committed(self):
self._rec("turn_committed")
def on_stream_discarded(self):
self._rec("stream_discarded")
def on_thinking_start(self):
self._rec("thinking_start")
def on_thinking_stop(self):
self._rec("thinking_stop")
def on_reasoning_token(self, text):
self._rec("reasoning", text)
def on_content_token(self, text):
self._rec("content", text)
def on_stream_end(self):
self._rec("stream_end")
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
pass
def on_status(self, usage, context_window, effort):
pass
def on_info(self, message):
self._rec("info", message)
def on_error(self, message):
self._rec("error", message)
def on_state_change(self, state):
self._rec("state", state)
def on_rename(self, name):
pass
def on_output_warning(self, call_id, assessment):
pass
def record_output_assessment(
self,
call_id,
assessment,
*,
tier="heuristic",
reasoning="",
judge_model="",
latency_ms=0,
confidence=0.0,
):
pass
def kinds(self):
return [k for k, _ in self.events]
def of(self, kind):
return [d for k, d in self.events if k == kind]
# ---------------------------------------------------------------------------
# Streaming provider fakes — the #832 seam contract
# ---------------------------------------------------------------------------
def make_result(
content: str = "",
*,
tool_calls: list[dict[str, Any]] | None = None,
finish_reason: str = "stop",
usage: UsageInfo | None = None,
native_blocks: list[dict[str, Any]] | None = None,
producer: str = "openai-compatible",
wire_msgs: list[dict[str, Any]] | None = None,
) -> ModelTurnResult:
"""A ``ModelTurnResult`` shaped like the streaming wrapper's return,
for tests that only need "a turn happened" and patch
``_stream_response`` wholesale. Turn and ``tool_calls`` mirror are
built from the same dicts, preserving the #825 pairing invariant."""
calls = list(tool_calls or [])
tc_tuple = tuple(
ToolCall(
id=tc.get("id", ""),
name=tc.get("function", {}).get("name", ""),
arguments=tc.get("function", {}).get("arguments", ""),
)
for tc in calls
)
native = (
ProviderNative(producer=producer, blocks=tuple(native_blocks)) if native_blocks else None
)
return ModelTurnResult(
turn=Turn.assistant(content, tool_calls=tc_tuple, native=native),
finish_reason=finish_reason,
usage=usage,
tool_calls=calls,
wire_msgs=wire_msgs,
producer=producer,
)
class ArmedHandle:
"""Closeable sentinel standing in for the SDK stream handle."""
def __init__(self) -> None:
self.closed = False
def close(self) -> None:
self.closed = True
def provider_shell(
name: str = "openai-compatible",
retryable: frozenset[str] = frozenset({"IncompleteStreamError"}),
) -> MagicMock:
"""The armed-provider fake skeleton every streaming fake builds on:
provider_name / capabilities / retryable set. ONE spelling, so a new
attribute the seam starts probing lands in every fake at once."""
provider = MagicMock()
provider.provider_name = name
provider.get_capabilities.return_value = ModelCapabilities()
provider.retryable_error_names = retryable
return provider
def arm_session(
session: Any,
*streams: Any,
retryable: frozenset[str] = frozenset({"IncompleteStreamError"}),
name: str = "openai-compatible",
) -> MagicMock:
"""Install a sequential multi-turn armed provider fake on *session*.
Each ``create_streaming`` call serves the next element of *streams*:
an iterable/generator is armed (a closeable sentinel appended to
``cancel_ref`` the eager append every real adapter performs, which
the creation-vs-midstream classifier keys on) and returned to be
consumed once; an EXCEPTION instance is raised at create time WITHOUT
arming, a creation-phase failure the per-lane ladder owns. Calls
beyond the script fail loudly: the strict finish gate rejects an
exhausted iterator rather than absorbing it as a silent empty turn,
so an under-scripted test must say so.
Title generation is latched off with a provider-LEVEL fake the
best-effort title lane would otherwise consume the first script
before the main loop ran.
"""
session._title_generated = True
provider = provider_shell(name, retryable)
# One handle PER CREATE (the real adapters' rule): `handles` records
# them all, `_armed_handle` is the latest.
provider._armed_handle = None
provider.handles = []
remaining = list(streams)
def _create(**kwargs: Any):
assert remaining, "arm_session: script exhausted — send looped for more turns than scripted"
nxt = remaining.pop(0)
if isinstance(nxt, BaseException):
raise nxt
ref = kwargs.get("cancel_ref")
if ref is not None:
handle = ArmedHandle()
provider.handles.append(handle)
provider._armed_handle = handle
ref.append(handle)
return iter(nxt) if not hasattr(nxt, "__next__") else nxt
provider.create_streaming = MagicMock(side_effect=_create)
session._provider = provider
return provider
def scripted_provider(chunks: list[StreamChunk]) -> MagicMock:
"""Provider fake replaying *chunks*, arming ``cancel_ref`` eagerly.
Assign to ``session._provider`` (never mutate a resolved provider
the create_provider singleton rule above). Each call returns a FRESH
iterator over the same script so ladder tests re-drive it; the armed
handle is appended per call, matching the one-handle-per-create
behavior of every real adapter.
"""
provider = provider_shell()
def _create(**kwargs: Any):
ref = kwargs.get("cancel_ref")
if ref is not None:
ref.append(ArmedHandle())
return iter(chunks)
provider.create_streaming = MagicMock(side_effect=_create)
return provider
+604
View File
@@ -0,0 +1,604 @@
"""Browser-fidelity SSE recovery harness helpers.
The load-bearing assembly for ``tests/test_sse_recovery_e2e.py``: a
``BrowserlikeSSEClient`` that speaks the exact wire contract the real
``turnstone/shared_static/interactive.js`` pane speaks, and the
assertion helpers the scenarios share. The server boot machinery lives
in ``_sse_recovery_server.py``.
Why a raw-socket SSE reader (and not ``httpx.stream``): the slow-consumer
overflow scenario needs the consumer to STALL stop reading the socket
so the server's SSE generator blocks on ``await send`` and stops draining
the per-UI listener queue, which then poisons at its cap. A faithful
stall needs (a) precise control over when bytes are read and (b) a small
``SO_RCVBUF`` so the in-flight backlog before poison stays bounded to
~100 KB instead of the client kernel's multi-MB autotuned default (which
would need tens of thousands of events to overflow). A raw socket gives
both; httpx (used here only for the plain ``/history`` request/response)
gives neither. This is ALSO closer to the browser: EventSource has a
bounded receive buffer, not an unbounded one.
Client contract mirrored from interactive.js (line references are to
that file on the ``fix/sse-truncated-resync`` branch):
- ``_last_event_id`` advances ONLY from SSE ``id:`` fields, and only
ring-buffer events carry one synthetic replay frames (connected /
status / state_change / in_progress_snapshot / replay_truncated /
stream_overflow) do not, exactly like ``EventSource.lastEventId``
(interactive.js onmessage ~1378).
- reconnect presents ``connectCursor = _truncatedFromCursor ??
_lastEventId`` as ``?last_event_id=`` (manual path) or a
``Last-Event-ID`` header (native EventSource auto-reconnect path)
(interactive.js connectSSE ~1328).
- on a ``replay_truncated`` envelope the client records the
truncation-time cursor keep-oldest (``_truncatedFromCursor =
_lastEventId`` only when null) and runs ``_loadHistoryThenConnect``
(disconnect /history adopt cursor reconnect); a FAILED
/history leaves the record armed so the reconnect re-presents the
truncation-time cursor and re-draws the envelope (interactive.js
handleEvent replay_truncated ~2303, _loadHistoryThenConnect ~1604,
_refetchHistory seedCursor ~1706).
"""
from __future__ import annotations
import contextlib
import json
import socket
import threading
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit
import httpx
if TYPE_CHECKING:
from collections.abc import Callable
# Small client receive buffer so a stalled consumer's in-flight backlog
# before the server-side poison stays bounded (~100 KB) instead of the
# multi-MB autotuned default. Paired with the server's small SO_SNDBUF
# (see _sse_recovery_server.build_recovery_server).
_CLIENT_RCVBUF = 2048
@dataclass
class SSEFrame:
"""One decoded SSE frame, tagged with the connection it arrived on.
``event_id`` is the ``id:`` field verbatim (a stringified integer,
or ``None`` for id-less synthetic frames the same string domain as
``EventSource.lastEventId``). ``etype`` is the ``type`` field of the
JSON ``data:`` payload (the application event type), distinct from
any SSE ``event:`` field, which the server never uses.
"""
conn_index: int
event_id: str | None
etype: str | None
payload: dict[str, Any] | None
raw: str
@property
def event_id_int(self) -> int | None:
if self.event_id is None:
return None
try:
return int(self.event_id)
except ValueError:
return None
class BrowserlikeSSEClient:
"""A single interactive pane's SSE + /history state machine.
Not thread-safe against concurrent public calls; drive it from one
test thread. Internally a per-connection reader thread decodes the
stream; ``stall()`` / ``resume()`` gate that thread's socket reads so
a test can build server-side backpressure without closing the
connection (the slow-consumer listener-queue-poison path).
"""
def __init__(self, base_url: str, ws_id: str, token: str) -> None:
parts = urlsplit(base_url)
self._host = parts.hostname or "127.0.0.1"
self._port = parts.port or 80
self._ws_id = ws_id
self._token = token
self._auth = {"Authorization": f"Bearer {token}"}
self._http = httpx.Client(
base_url=f"http://{self._host}:{self._port}", timeout=httpx.Timeout(15.0)
)
# EventSource-equivalent cursor state.
self._last_event_id: str | None = None
self._truncated_from_cursor: str | None = None
# Transcript. ``_all_frames`` is the cross-connection accumulation
# (what "the client eventually saw"); ``_conn_frames`` keeps each
# connection's slice for per-connection assertions (contiguity).
self._all_frames: list[SSEFrame] = []
self._conn_frames: list[list[SSEFrame]] = []
self._frames_lock = threading.Lock()
# Reader plumbing.
self._sock: socket.socket | None = None
self._reader: threading.Thread | None = None
self._stop = threading.Event()
self._read_gate = threading.Event()
self._read_gate.set() # reading permitted by default
self._status: int | None = None
self._headers_done = threading.Event()
# -- connection lifecycle ------------------------------------------------
def _events_path(self, cursor: str | None) -> str:
path = f"/v1/api/workstreams/{self._ws_id}/events"
if cursor is not None:
path += f"?last_event_id={cursor}"
return path
def connect(self, *, native: bool = False, rcvbuf: int | None = None) -> None:
"""Open the SSE stream, presenting the client's current cursor.
``native=True`` models the browser's EventSource auto-reconnect:
the cursor rides a ``Last-Event-ID`` HEADER and never appears in
the URL. ``native=False`` models the manual ``new EventSource(url
+ '?last_event_id=')`` path interactive.js uses when it must
override the live cursor (the ``connectCursor`` chokepoint).
``rcvbuf`` shrinks this connection's ``SO_RCVBUF`` — pass
``_CLIENT_RCVBUF`` on a connection the test will ``stall()`` so the
in-flight backlog before the server-side poison stays bounded.
Leave it ``None`` (OS default) on recovery reconnects so the ring
replay is not throttled to a crawl.
"""
if self._reader is not None:
raise RuntimeError("already connected; disconnect() first")
connect_cursor = (
self._truncated_from_cursor
if self._truncated_from_cursor is not None
else self._last_event_id
)
header_lines = [
f"Host: {self._host}:{self._port}",
f"Authorization: Bearer {self._token}",
"Accept: text/event-stream",
"Cache-Control: no-cache",
]
if native:
path = self._events_path(None)
if connect_cursor is not None:
header_lines.append(f"Last-Event-ID: {connect_cursor}")
else:
path = self._events_path(connect_cursor)
request = f"GET {path} HTTP/1.1\r\n" + "\r\n".join(header_lines) + "\r\n\r\n"
sock = socket.create_connection((self._host, self._port), timeout=10)
if rcvbuf is not None:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.settimeout(None)
sock.sendall(request.encode())
self._sock = sock
self._stop.clear()
self._read_gate.set()
self._status = None
self._headers_done.clear()
conn_index = len(self._conn_frames)
frames: list[SSEFrame] = []
self._conn_frames.append(frames)
self._reader = threading.Thread(
target=self._read_loop,
args=(sock, conn_index, frames),
name=f"sse-reader-{self._ws_id[:6]}-{conn_index}",
daemon=True,
)
self._reader.start()
# Surface a non-200 handshake to the caller (409 half-built UI,
# 404 unknown ws, 401 auth) rather than silently reading nothing.
if not self._headers_done.wait(timeout=10):
self.disconnect()
raise AssertionError("events connect: no HTTP response headers")
if self._status != 200:
status = self._status
self.disconnect()
raise AssertionError(f"events connect returned HTTP {status}")
def disconnect(self) -> None:
"""Close the stream and join the reader (leak-guard clean)."""
self._stop.set()
self._read_gate.set() # release a stalled reader so it sees _stop
sock = self._sock
if sock is not None:
with contextlib.suppress(OSError):
sock.shutdown(socket.SHUT_RDWR) # interrupt a blocked recv
reader = self._reader
if reader is not None:
reader.join(timeout=15)
if reader.is_alive():
raise AssertionError("SSE reader thread failed to stop")
if sock is not None:
with contextlib.suppress(OSError):
sock.close()
self._sock = None
self._reader = None
def close(self) -> None:
"""Full teardown: disconnect any live stream + close the HTTP client."""
if self._reader is not None:
self.disconnect()
self._http.close()
# -- the stall gate (backpressure driver) --------------------------------
def stall(self) -> None:
"""Stop reading the socket. The kernel + uvicorn send buffers fill,
blocking the server's SSE generator on its ``await send``, so it
stops draining the per-UI listener queue which poisons at its cap.
"""
self._read_gate.clear()
def resume(self) -> None:
"""Resume reading. A poisoned-and-closed stream delivers its
``stream_overflow`` farewell frame once the backlog drains."""
self._read_gate.set()
# -- reader --------------------------------------------------------------
def _read_loop(self, sock: socket.socket, conn_index: int, frames: list[SSEFrame]) -> None:
raw = b"" # undecoded bytes (headers, then chunked framing)
sse = b"" # decoded SSE byte stream
headers_parsed = False
chunked = False
while not self._stop.is_set():
# Backpressure gate: while stalled we do NOT read the socket, so
# its receive buffer fills and TCP flow control stalls the server.
if not self._read_gate.wait(timeout=0.1):
continue
if self._stop.is_set():
break
try:
chunk = sock.recv(65536)
except OSError:
break
if not chunk:
break # server closed
raw += chunk
if not headers_parsed:
if b"\r\n\r\n" not in raw:
continue
header_blob, raw = raw.split(b"\r\n\r\n", 1)
self._parse_headers(header_blob)
chunked = b"transfer-encoding: chunked" in header_blob.lower()
headers_parsed = True
self._headers_done.set()
if chunked:
decoded, raw = _dechunk(raw)
sse += decoded
else:
sse += raw
raw = b""
sse = sse.replace(b"\r\n", b"\n")
while b"\n\n" in sse:
block, sse = sse.split(b"\n\n", 1)
self._handle_block(block.decode("utf-8", "replace"), conn_index, frames)
def _parse_headers(self, header_blob: bytes) -> None:
first_line = header_blob.split(b"\r\n", 1)[0].decode("latin-1")
# "HTTP/1.1 200 OK"
parts = first_line.split(" ", 2)
if len(parts) >= 2 and parts[1].isdigit():
self._status = int(parts[1])
def _handle_block(self, block_text: str, conn_index: int, frames: list[SSEFrame]) -> None:
event_id: str | None = None
data_parts: list[str] = []
retry: str | None = None
for line in block_text.split("\n"):
if not line or line.startswith(":"):
continue # blank or comment (ping)
field_name, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:] # SSE strips a single leading space
if field_name == "id":
event_id = value
elif field_name == "data":
data_parts.append(value)
elif field_name == "retry":
retry = value
# EventSource semantics: an event carrying an ``id:`` sets the
# last-event-id buffer; an event without one leaves it unchanged.
if event_id is not None:
self._last_event_id = event_id
if not data_parts:
if retry is not None:
self._record(SSEFrame(conn_index, None, "retry", None, block_text), frames)
return
data_str = "\n".join(data_parts)
payload: dict[str, Any] | None
try:
parsed = json.loads(data_str)
payload = parsed if isinstance(parsed, dict) else None
except ValueError:
payload = None
etype = payload.get("type") if payload is not None else None
frame = SSEFrame(conn_index, event_id, etype, payload, data_str)
self._record(frame, frames)
# Mirror the pane: the FIRST replay_truncated for an unrepaired gap
# records the truncation-time cursor (keep-oldest). Its consumer is
# the reconnect chokepoint (see ``connect``).
if etype == "replay_truncated" and self._truncated_from_cursor is None:
self._truncated_from_cursor = self._last_event_id
def _record(self, frame: SSEFrame, frames: list[SSEFrame]) -> None:
with self._frames_lock:
frames.append(frame)
self._all_frames.append(frame)
# -- /history + cursor flow ----------------------------------------------
def fetch_history(self) -> dict[str, Any]:
"""GET /history and return the parsed JSON ({ws_id, messages, cursor})."""
r = self._http.get(f"/v1/api/workstreams/{self._ws_id}/history", headers=self._auth)
r.raise_for_status()
result: dict[str, Any] = r.json()
return result
def seed_from_history(self) -> dict[str, Any]:
"""The seedCursor step: fetch /history, adopt a non-null resume
cursor into ``_last_event_id``, and clear the truncation record on
a successful render (replayHistory clears ``_truncatedFromCursor``).
"""
data = self.fetch_history()
cursor = data.get("cursor")
if cursor is not None:
self._last_event_id = str(cursor)
self._truncated_from_cursor = None # successful full render repairs the gap
return data
def load_history_then_connect(
self, *, fail_history: bool = False, native: bool = False
) -> dict[str, Any] | None:
"""Reproduce interactive.js ``_loadHistoryThenConnect``.
Disconnect first, drop the live cursor (``_last_event_id = None``)
but KEEP ``_truncated_from_cursor`` armed, then fetch /history and
reconnect. On success adopt the returned cursor and clear the
truncation record; on a FAILED /history (``fail_history`` the
harness IS the client here, so a client-side simulated failure is
faithful) leave the record armed so the reconnect re-presents the
truncation-time cursor and re-draws ``replay_truncated``.
Returns the /history JSON, or ``None`` when the fetch failed.
"""
if self._reader is not None:
self.disconnect()
self._last_event_id = None
data: dict[str, Any] | None
if fail_history:
data = None
else:
data = self.fetch_history()
cursor = data.get("cursor")
if cursor is not None:
self._last_event_id = str(cursor)
self._truncated_from_cursor = None
self.connect(native=native)
return data
# -- accessors + waits ---------------------------------------------------
@property
def last_event_id(self) -> str | None:
return self._last_event_id
@property
def truncated_from_cursor(self) -> str | None:
return self._truncated_from_cursor
def all_frames(self) -> list[SSEFrame]:
with self._frames_lock:
return list(self._all_frames)
def conn_frames(self, conn_index: int) -> list[SSEFrame]:
with self._frames_lock:
return list(self._conn_frames[conn_index])
def latest_conn_frames(self) -> list[SSEFrame]:
with self._frames_lock:
return list(self._conn_frames[-1]) if self._conn_frames else []
def num_connections(self) -> int:
with self._frames_lock:
return len(self._conn_frames)
def frames_of_type(self, etype: str) -> list[SSEFrame]:
return [f for f in self.all_frames() if f.etype == etype]
def has_type(self, etype: str) -> bool:
return any(f.etype == etype for f in self.all_frames())
def tool_output_by_call(self) -> dict[str, str]:
"""Concatenate every ``tool_output_chunk`` payload per call_id, in
arrival order the reconstructed live stream for each call."""
out: dict[str, str] = {}
for f in self.all_frames():
if f.etype == "tool_output_chunk" and f.payload is not None:
cid = str(f.payload.get("call_id", ""))
out[cid] = out.get(cid, "") + str(f.payload.get("chunk", ""))
return out
def tool_results_by_call(self) -> dict[str, str]:
"""The last ``tool_result`` output seen per call_id."""
out: dict[str, str] = {}
for f in self.all_frames():
if f.etype == "tool_result" and f.payload is not None:
out[str(f.payload.get("call_id", ""))] = str(f.payload.get("output", ""))
return out
def wait_for_type(self, etype: str, *, timeout: float = 45.0) -> SSEFrame:
"""Block until a frame of ``etype`` has arrived on ANY connection."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for f in self.all_frames():
if f.etype == etype:
return f
time.sleep(0.05)
raise AssertionError(f"timed out waiting for a {etype!r} frame")
def wait_for(
self, predicate: Callable[[BrowserlikeSSEClient], bool], *, timeout: float = 45.0
) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate(self):
return
time.sleep(0.05)
raise AssertionError("timed out waiting for predicate")
def wait_for_call_result(self, call_id: str, *, timeout: float = 45.0) -> None:
self.wait_for(lambda c: call_id in c.tool_results_by_call(), timeout=timeout)
def _dechunk(buf: bytes) -> tuple[bytes, bytes]:
"""Incrementally decode HTTP/1.1 chunked transfer-encoding.
Consumes as many COMPLETE chunks from ``buf`` as possible and returns
``(decoded_bytes, remainder)`` where ``remainder`` is the trailing
partial chunk to carry into the next read. A zero-length chunk (stream
end) simply stops consumption; the reader's ``recv`` EOF handles close.
"""
decoded = b""
while True:
if b"\r\n" not in buf:
break # incomplete size line
size_line, rest = buf.split(b"\r\n", 1)
try:
n = int(size_line.strip() or b"z", 16)
except ValueError:
break # malformed / partial — wait for more bytes
if n == 0:
break # last chunk marker
if len(rest) < n + 2: # need n data bytes + trailing CRLF
break
decoded += rest[:n]
buf = rest[n + 2 :]
return decoded, buf
# ---------------------------------------------------------------------------
# Assertion helpers (shared by the scenarios).
# ---------------------------------------------------------------------------
def assert_contiguous_ids(frames: list[SSEFrame]) -> None:
"""Every id-bearing frame in a connection forms a gap-free, dup-free,
strictly increasing run.
Holds for a connection that took no ``_seq``-filtered fresh path a
fresh connect made before any event (snap_seq == 0) and every
``replay_ok`` reconnect (snap_seq == 0). The server stamps a fresh
monotonic id per enqueue with no in-ring coalescing, so a
non-filtered consumer sees consecutive ids.
"""
ids = [f.event_id_int for f in frames if f.event_id_int is not None]
assert ids, "connection carried no id-bearing frames"
assert len(set(ids)) == len(ids), f"duplicate SSE ids: {ids}"
assert ids == sorted(ids), f"SSE ids not monotonic: {ids}"
for prev, cur in zip(ids, ids[1:], strict=False):
assert cur == prev + 1, f"gap in SSE ids between {prev} and {cur}: {ids}"
def assert_ids_monotonic_no_dupes(frames: list[SSEFrame]) -> None:
"""Weaker invariant that holds on EVERY connection (including
``_seq``-filtered fresh/truncated paths, where gaps are legal): ids
are strictly increasing with no duplicates."""
ids = [f.event_id_int for f in frames if f.event_id_int is not None]
assert len(set(ids)) == len(ids), f"duplicate SSE ids: {ids}"
assert ids == sorted(ids), f"SSE ids not monotonic: {ids}"
def assert_chunk_result_ordering(frames: list[SSEFrame]) -> None:
"""Every ``tool_output_chunk`` for a call precedes that call's own
``tool_result`` on the wire (the load-bearing ordering the client
removes the streaming <pre> when it renders the result)."""
result_index: dict[str, int] = {}
for i, f in enumerate(frames):
if f.etype == "tool_result" and f.payload is not None:
result_index[str(f.payload.get("call_id", ""))] = i
for i, f in enumerate(frames):
if f.etype == "tool_output_chunk" and f.payload is not None:
cid = str(f.payload.get("call_id", ""))
assert cid in result_index, f"chunk for call {cid} has no tool_result"
assert i < result_index[cid], (
f"chunk for call {cid} arrived AFTER its tool_result "
f"(chunk idx {i} >= result idx {result_index[cid]})"
)
def assert_children_stamped(frames: list[SSEFrame], parent_call_id: str) -> None:
"""Every sub-agent child tool event carries ``parent_call_id`` (stamped
at the flush chokepoint). Sub-tool call_ids are minted
``{parent}::r{run}s{step}::{provider_id}`` the ``::`` segment is the
identifying mark and NONE may escape unstamped to the top level."""
unstamped: list[tuple[str | None, str, Any]] = []
stamped = 0
for f in frames:
if f.payload is None:
continue
items = f.payload.get("items")
entries = items if isinstance(items, list) else [f.payload]
for entry in entries:
if not isinstance(entry, dict):
continue
cid = str(entry.get("call_id", ""))
if "::" not in cid:
continue
if entry.get("parent_call_id") == parent_call_id:
stamped += 1
else:
unstamped.append((f.etype, cid, entry.get("parent_call_id")))
assert stamped > 0, f"no child events found for parent {parent_call_id}"
assert not unstamped, f"child events escaped unstamped (parent {parent_call_id}): {unstamped}"
def history_tool_outputs(history_json: dict[str, Any]) -> dict[str, str]:
"""Extract {call_id: output} from a /history projection, however the
projection surfaces results (a folded ``output`` on a tool_call, or a
trailing ``role: tool`` row keyed by ``tool_call_id``)."""
out: dict[str, str] = {}
for msg in history_json.get("messages", []):
if not isinstance(msg, dict):
continue
if msg.get("role") == "tool":
cid = msg.get("tool_call_id") or msg.get("call_id")
if cid is not None:
out[str(cid)] = str(msg.get("content", ""))
for tc in msg.get("tool_calls") or ():
if not isinstance(tc, dict):
continue
cid = tc.get("id") or tc.get("call_id")
if cid is not None and tc.get("output") is not None:
out[str(cid)] = str(tc.get("output", ""))
return out
def assert_converged(client: BrowserlikeSSEClient, history_json: dict[str, Any]) -> None:
"""Turn-level equivalence: every tool result the client assembled live
is present, with the same output, in a fresh /history projection.
Compares by call_id so a reconnect that re-delivered a result can't
hide a divergence, and asserts the /history side isn't empty (a
silently-lost turn would leave the projection short)."""
live = client.tool_results_by_call()
hist = history_tool_outputs(history_json)
assert hist, "fresh /history projected no tool results — a turn was lost"
for call_id, output in live.items():
assert call_id in hist, f"call {call_id} seen live but absent from /history: {sorted(hist)}"
assert hist[call_id] == output, (
f"call {call_id} output diverged: live={output!r} history={hist[call_id]!r}"
)
+628
View File
@@ -0,0 +1,628 @@
"""Boot the REAL interactive Turnstone server for the SSE recovery e2e
harness: real ``SessionManager`` + real ``ChatSession`` engine driven
through a scripted chat-completions client at the SDK boundary, executing
REAL bash tools, exposed over a real uvicorn socket.
The recipe (verified end-to-end) has four load-bearing pieces:
1. **Provider injection seam.** ``create_app`` takes a PRE-BUILT
``SessionManager``, so the harness owns the ``session_factory``: it
passes ``client=fake_client`` and OMITS the registry, so
``ChatSession`` falls back to ``create_provider("openai-compatible")``
== ``OpenAIChatCompletionsProvider`` exactly what
``tests._session_helpers.scripted_chat_client`` targets. No production
monkeypatch of the engine.
2. **Auto-title suppression.** The first user message spawns a background
``_generate_title`` LLM call that would consume the first scripted
response (the tool call) and desync a positional script. Setting
``session._title_generated = True`` before the first send disables it.
3. **Completion barrier.** ``/send`` returns immediately after spawning
``ws.worker_thread``; joining that thread is the true "turn complete,
every SSE event enqueued" barrier (``stream_end`` is per-LLM-call, not
per-turn, so it is NOT a completion marker).
4. **Thread hygiene.** ``create_app``'s lifespan starts daemon fan-out
threads (``_global_fanout_thread`` blocking on ``global_queue.get()``,
``_aggregate_emitter_thread`` on a 10s loop). The harness used to
swap them for no-ops (they had no shutdown and tripped conftest's
leaked-thread guard), which kept the global lane dead here; #885 gave
the lifespan a real shutdown (stop Event + a queue sentinel for the
fanout, joined in the lifespan exit that ``stop()``'s
``should_exit``/join drives), so the harness now runs them REAL the
``roster-restart`` scenario depends on a live global lane and
teardown stays clean with no ``allow_thread_leak``.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import queue as _q
import socket
import threading
import time
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import httpx
import uvicorn
from tests._session_helpers import scripted_chat_client
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
from turnstone.core.session import ChatSession
from turnstone.core.session_manager import SessionManager
from turnstone.core.session_ui_base import SessionUIBase
from turnstone.core.storage import get_storage
from turnstone.core.workstream import WorkstreamKind
from turnstone.prompts import ClientType
from turnstone.server import WebUI, create_app
if TYPE_CHECKING:
from collections.abc import MutableMapping
from turnstone.core.workstream import Workstream
_JWT_SECRET = "sse-recovery-e2e-jwt-secret-minimum-32-chars!"
# Small server send buffer so a stalled consumer's in-flight backlog before
# the listener-queue poison stays bounded (paired with the client's small
# SO_RCVBUF in _sse_recovery_helpers). Harmless for prompt readers.
_DEFAULT_SNDBUF = 8192
def _fake_client(scripts: tuple[Any, ...]) -> Any:
"""An SDK-shaped fake whose ``chat.completions.create`` follows a
positional script (each a :func:`fake_chat_stream` kwargs dict)."""
create_fn = scripted_chat_client(*scripts)
client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create_fn)))
client.calls = create_fn.calls
return client
class RecoveryServer:
"""A booted interactive node the recovery scenarios drive."""
def __init__(
self,
*,
sndbuf: int = _DEFAULT_SNDBUF,
listener_cap: int | None = None,
extra_routes: list[Any] | None = None,
port: int = 0,
sock: socket.socket | None = None,
) -> None:
self._global_queue: _q.Queue[dict[str, Any]] = _q.Queue(maxsize=100000)
self._global_listeners: list[_q.Queue[dict[str, Any]]] = []
self._global_listeners_lock = threading.Lock()
# Per-ws scripted client, resolved at factory-call time.
self._pending_client: Any = _fake_client((dict(content="ok", finish_reason="stop"),))
self._clients: dict[str, Any] = {}
WebUI._global_queue = self._global_queue
def session_factory(
ui: Any,
model_alias: str | None = None,
ws_id: str | None = None,
*,
skill: Any = None,
client_type: str = "",
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE,
parent_ws_id: str | None = None,
project_id: str = "",
**_extra: Any,
) -> ChatSession:
client = self._pending_client
if ws_id is not None:
self._clients[ws_id] = client
return ChatSession(
client=client,
model="test-model",
ui=ui,
instructions=None,
temperature=None,
max_tokens=1024,
tool_timeout=30,
ws_id=ws_id,
user_id="recovery-user",
client_type=ClientType.WEB,
kind=kind,
# Don't truncate large tool outputs: the harness tests
# recovery, not the tool-result truncation budget, and a
# truncated /history would diverge from the full live event
# and defeat the convergence assertions.
tool_truncation=10_000_000,
)
self._adapter = InteractiveAdapter(
global_queue=self._global_queue,
ui_factory=lambda ws: WebUI(
ws_id=ws.id, user_id=ws.user_id, kind=ws.kind, parent_ws_id=ws.parent_ws_id
),
session_factory=session_factory,
)
self._manager = SessionManager(
self._adapter, storage=get_storage(), max_active=32, node_id="recovery-node"
)
self._adapter.attach(self._manager)
WebUI._workstream_mgr = self._manager
# delay_load knob state (see the method): wraps the storage
# singleton's load_messages; restored in stop().
self._load_delay_ms = 0
self._load_calls = 0
# load_messages runs on asyncio.to_thread WORKERS, and delay_load
# exists precisely to overlap two of them — unlike the
# single-writer HTTP counters, this one has genuine concurrent
# writers, so the increment takes a lock (a lost update would
# false-FAIL G7's load_delta === 2, or mask a third load).
self._load_calls_lock = threading.Lock()
_storage_obj = get_storage()
self._orig_load_messages = _storage_obj.load_messages
def _delayed_load(*a: Any, **k: Any) -> Any:
with self._load_calls_lock:
self._load_calls += 1
result = self._orig_load_messages(*a, **k)
# Sleep AFTER the load: the held flight must hold the data it
# actually read (its transaction point), so a flight parked
# across a rewind genuinely carries PRE-rewind rows — a
# pre-load sleep would read post-rewind storage and mask a
# wrongly-joined flight as fresh truth.
d = self._load_delay_ms
if d > 0:
time.sleep(d / 1000.0)
return result
_storage_obj.load_messages = _delayed_load # type: ignore[method-assign]
self._patched_storage = _storage_obj
# Optional small listener-queue cap. The cap is a default arg on the
# registration methods with no config/env override, so lower it by
# patching their ``__defaults__`` (restored on stop). fix-3's
# de-amplification makes a real 500-cap overflow need a pathological
# storm; a small cap exercises the identical _ListenerOverflow ->
# stream_overflow -> reconnect-replay path within a bounded storm.
self._orig_defaults: list[tuple[Any, tuple[Any, ...] | None]] = []
if listener_cap is not None:
for meth in (
SessionUIBase._register_listener,
SessionUIBase.register_listener_with_in_progress_snapshot,
SessionUIBase.register_listener_with_replay,
):
self._orig_defaults.append((meth, meth.__defaults__))
meth.__defaults__ = (listener_cap,)
self._app = create_app(
workstreams=self._manager,
global_queue=self._global_queue,
global_listeners=self._global_listeners,
global_listeners_lock=self._global_listeners_lock,
skip_permissions=True,
jwt_secret=_JWT_SECRET,
node_id="recovery-node",
# /history + tenant checks read app.state.auth_storage.
auth_storage=get_storage(),
)
# Same-origin extras (Tier 2 serves its recovery page here so the real
# Pane's cookie auth + EventSource work without cross-origin plumbing).
if extra_routes:
self._app.router.routes.extend(extra_routes)
# Pre-bind a listening socket with a small SO_SNDBUF (accepted conns
# inherit it), then hand it to uvicorn. ``sock`` injection: the
# gap-free restart scenarios (roster-restart-native) bind a
# placeholder BEFORE stopping the prior node and hand it in here —
# a failed EventSource reconnect attempt is TERMINAL per WHATWG
# (fail-the-connection → CLOSED, no further retries), so the
# native-retry leg must never observe a refused-window; the
# placeholder's listen backlog completes the TCP handshake during
# the boot and uvicorn drains it once serving.
self._sock = sock if sock is not None else make_listen_socket(port, sndbuf=sndbuf)
self._port = int(self._sock.getsockname()[1])
# -- fault injection (public knobs below) ----------------------------
# In-process arming: the Tier-2 runner holds this RecoveryServer and
# arms a knob, THEN drives the browser request that consumes it.
# Single-writer by construction — the runner never arms a knob while
# the loop thread is mid-consume — and CPython makes each int read /
# write atomic, so these need no lock even though the uvicorn loop
# thread increments/decrements them while the runner thread reads.
self.history_requests = 0
# /history responses the PRODUCTION route answered 200 (not the
# fault layer's injected 500s). See _fault_app for why arrival
# counting cannot substitute.
self.history_ok = 0
self.rewind_requests = 0
# Per-ws SSE connection opens (``GET …/events`` — the EventSource the
# pane's connectSSE builds). A TRANSPORT-FREE heal (the #890 idle-edge
# staleness backstop, a quiesced REST refetch) must leave this FLAT; a
# reload-based backstop would bump it once per reconnect (the round-5
# storm). Same lock-free single-writer int discipline as above.
self.events_requests = 0
# Global-lane SSE connection opens (``GET …/events/global`` — the
# roster stream app.js's connectGlobalSSE builds). The
# roster-restart scenario (#881) asserts the post-restart manual
# reconnect actually reached the reborn node's real endpoint.
self.global_events_requests = 0
self._history_fail_remaining = 0
self._history_delay_ms = 0
# A thin pure-ASGI fault layer wrapping the REAL app (the production
# app itself is untouched): count + optionally delay/fail
# ``GET …/history``, count ``POST …/rewind``, count each per-ws SSE
# connection open (``GET …/events``), forward everything else (SSE
# bodies, /send, lifespan, static) verbatim.
production_app = self._app
async def _fault_app(scope: dict[str, Any], receive: Any, send: Any) -> None:
if scope.get("type") == "http":
path = scope.get("path", "")
method = scope.get("method", "")
if path.endswith("/history") and method == "GET":
# ARRIVAL, never move. Scenarios that hold a request open
# use this bump as the IN-FLIGHT edge (E6/E7/G1/G7 say so
# at their poll sites); counting on forward instead would
# delay it past the hold and silently stop those scenarios
# testing anything.
self.history_requests += 1
if self._history_delay_ms > 0:
await asyncio.sleep(self._history_delay_ms / 1000.0)
if self._history_fail_remaining > 0:
self._history_fail_remaining -= 1
await send(
{
"type": "http.response.start",
"status": 500,
"headers": [(b"content-type", b"application/json")],
}
)
await send({"type": "http.response.body", "body": b'{"error": "injected"}'})
return
# Successful-RESPONSE counter, distinct from the arrival
# bump above. A scenario asserting that a render was
# DECLINED needs to know a good payload actually existed —
# otherwise "the client refused to render" and "there was
# nothing to render" produce identical observables (no
# wipe, latch held). Arrival cannot prove that, and
# neither can an injected-fail budget: a PRODUCTION-side
# 500/404 would slip through both. Reading the real
# status off the response start is the only honest signal.
async def _counting_send(message: MutableMapping[str, Any]) -> None:
if (
message.get("type") == "http.response.start"
and message.get("status") == 200
):
self.history_ok += 1
await send(message)
await production_app(scope, receive, _counting_send)
return
elif path.endswith("/rewind") and method == "POST":
self.rewind_requests += 1
elif path.endswith("/events") and method == "GET":
# Per-ws SSE connection open — count it (readable on
# RecoveryServer) and forward the long-lived stream
# verbatim below. Uniquely the per-ws stream: the global
# lane is ``…/events/global`` (ends ``/global``), and the
# route the pane's EventSource hits is
# ``…/workstreams/{ws_id}/events`` (session_routes).
self.events_requests += 1
elif path.endswith("/events/global") and method == "GET":
self.global_events_requests += 1
await production_app(scope, receive, send)
# ``timeout_graceful_shutdown``: an SSE stream that is still open
# at ``stop()`` would otherwise park uvicorn's graceful drain
# indefinitely (the 20s thread-join just expires and the browser
# stays attached to the zombie server — the roster-restart-native
# scenario is the one caller that stops a node mid-stream). A
# bounded drain force-closes the stream after 2s and the lifespan
# shutdown (#885's daemon-thread teardown) still runs after it.
self._server = uvicorn.Server(
uvicorn.Config(
_fault_app,
log_level="warning",
lifespan="on",
timeout_graceful_shutdown=2,
)
)
self._thread = threading.Thread(
target=self._serve, name=f"uvicorn-recovery-{self._port}", daemon=True
)
self._thread.start()
if not _tcp_ready(self._port, 10.0):
self.stop()
raise AssertionError("recovery server did not accept TCP")
self._token = create_jwt(
user_id="recovery-user",
scopes=frozenset({"read", "write", "approve", "service"}),
source="recovery",
secret=_JWT_SECRET,
audience=JWT_AUD_SERVER,
)
self._http = httpx.Client(base_url=self.base_url, timeout=httpx.Timeout(30.0))
def _serve(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(self._server.serve(sockets=[self._sock]))
finally:
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
with contextlib.suppress(Exception):
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.close()
# -- properties ----------------------------------------------------------
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self._port}"
@property
def token(self) -> str:
return self._token
@property
def manager(self) -> SessionManager:
return self._manager
# -- workstream lifecycle ------------------------------------------------
def create_workstream(self, *scripts: Any, name: str = "recovery-ws") -> str:
"""Create a ws whose scripted LLM follows ``scripts`` (positional
:func:`fake_chat_stream` kwargs). Auto-approves tools and suppresses
the auto-title call so the positional script stays in sync."""
self._pending_client = _fake_client(scripts)
ws = self._manager.create(user_id="recovery-user", name=name)
self._prime_ws(ws)
return ws.id
def open_workstream(self, ws_id: str, *scripts: Any) -> None:
"""Rehydrate a persisted ws on THIS node (the restart path). Fresh
UI empty ring + storage-seeded ``_event_id``."""
if scripts:
self._pending_client = _fake_client(scripts)
ws = self._manager.open(ws_id)
if ws is None:
raise AssertionError(f"open_workstream: ws {ws_id} not resurrectable")
self._prime_ws(ws)
def _prime_ws(self, ws: Workstream) -> None:
if isinstance(ws.ui, SessionUIBase):
ws.ui.auto_approve = True # blanket tool auto-approval
if ws.session is not None:
ws.session._title_generated = True # suppress the auto-title LLM call
def send(self, ws_id: str, message: str = "go") -> None:
"""POST /send — spawns the worker thread and returns immediately."""
r = self._http.post(
f"/v1/api/workstreams/{ws_id}/send",
headers={"Authorization": f"Bearer {self._token}"},
json={"message": message},
)
r.raise_for_status()
def wait_turn(self, ws_id: str, *, timeout: float = 45.0) -> None:
"""Block until the turn's worker thread finishes (the true
turn-complete barrier) and the ws is idle."""
deadline = time.monotonic() + timeout
worker: threading.Thread | None = None
while time.monotonic() < deadline:
ws = self._manager.get(ws_id)
worker = ws.worker_thread if ws is not None else None
if worker is not None:
break
time.sleep(0.02)
if worker is not None:
worker.join(timeout=max(0.5, deadline - time.monotonic()))
if worker.is_alive():
raise AssertionError(f"turn worker for {ws_id} did not finish in {timeout}s")
def get_ws(self, ws_id: str) -> Workstream | None:
return self._manager.get(ws_id)
def ws_state(self, ws_id: str) -> str:
ws = self._manager.get(ws_id)
return ws.state.value if ws is not None else ""
def ring_span(self, ws_id: str) -> tuple[int | None, int]:
"""(earliest retained ring event_id or None, latest counter) — lets a
scenario wait for the ring to evict a specific cursor."""
ws = self._manager.get(ws_id)
ui = ws.ui if ws is not None else None
if not isinstance(ui, SessionUIBase):
return None, 0
buf = ui._event_buffer
earliest = buf[0][0] if buf else None
return earliest, ui._event_id
def listener_poisoned(self, ws_id: str) -> bool:
"""True once any live SSE listener on the ws has poisoned (overflow)."""
ws = self._manager.get(ws_id)
ui = ws.ui if ws is not None else None
if not isinstance(ui, SessionUIBase):
return False
return any(getattr(q, "poisoned", False) for q in list(ui._listeners))
def max_event_id(self, ws_id: str) -> int | None:
"""The storage high-water ``MAX(conversations.event_id)`` — what a
restarted node's fresh UI seeds ``_event_id`` from."""
result: int | None = get_storage().get_max_event_id(ws_id)
return result
def fetch_history(self, ws_id: str) -> dict[str, Any]:
r = self._http.get(
f"/v1/api/workstreams/{ws_id}/history",
headers={"Authorization": f"Bearer {self._token}"},
)
r.raise_for_status()
result: dict[str, Any] = r.json()
return result
# -- fault-injection knobs -----------------------------------------------
# Armed in-process by the Tier-2 runner (single writer at a time — see
# __init__). A plain int is deliberate: CPython makes the loop thread's
# increment/decrement and the runner thread's read each atomic, and the
# arm-then-consume ordering means they never race.
def delay_load(self, ms: int) -> None:
"""Hold ``storage.load_messages`` itself open for ``ms`` (0 = off).
``delay_history`` sleeps in the FAULT LAYER before the route
so two delayed requests never overlap inside the #884 flight
machinery (the first flight completes and pops before the second
arrives at the route). This knob sleeps INSIDE the shared
reconstruction's ``load_messages`` (sync, called via
``asyncio.to_thread`` the sleep parks only that worker), which
is the same layer the unit tests gate, so held flights genuinely
overlap and join/miss behavior is observable end to end via
``load_calls``.
"""
self._load_delay_ms = ms
@property
def load_calls(self) -> int:
"""``load_messages`` entries (pre-sleep) — the flight-layer twin
of ``history_requests`` (which counts HTTP arrivals): a JOINED
request never enters ``load_messages``, so join=1 / miss=2."""
return self._load_calls
def fail_history(self, count: int) -> None:
"""Make the next ``count`` ``GET …/history`` responses a 500 — the
failed refetch the #890 guard-before-wipe must survive."""
self._history_fail_remaining = count
def delay_history(self, ms: int) -> None:
"""Hold each ``GET …/history`` ``ms`` ms before forwarding (0
clears). Opens the clear_ui-refetch quiesce window that the row
affordance gate (``busy || _historyStale``) must close."""
self._history_delay_ms = ms
@property
def history_fail_remaining(self) -> int:
"""Unconsumed forced-failure budget — 0 proves the armed failure
actually fired (assert backend state, never scripted absence)."""
return self._history_fail_remaining
# -- teardown ------------------------------------------------------------
def stop(self, *, hard: bool = False) -> None:
"""Stop the node.
``hard=True`` skips the per-workstream ``manager.close`` sweep a
graceful close routes through ``cleanup_session_ui``
``session.cancel()``, whose bash cancel path PERSISTS a
synthesized "Cancelled by user" result while the old node is
still alive, which masks crash states. A hard stop leaves any
in-flight tool call genuinely unresulted in storage, modelling a
SIGKILL/OOM death (the coord-orphan-rewind scenario's premise).
The 2s graceful-shutdown timeout (uvicorn config) force-closes
open SSE streams, and the lifespan teardown still runs, so the
#885 daemon threads are joined on both paths.
"""
if not hard:
with contextlib.suppress(Exception):
for ws in list(self._manager.list_all()):
with contextlib.suppress(Exception):
self._manager.close(ws.id)
# hard=True relies on ``timeout_graceful_shutdown=2`` (set in the
# uvicorn config above) to force-close the pane's EventSource:
# should_exit alone still runs the ASGI lifespan teardown, so the
# #885 daemon threads and the sse_executor are joined either way
# (``force_exit`` would SKIP the lifespan and leak them — the
# fanout thread blocks on queue.get() forever). NOTE: the killed
# workstream's in-flight tool keeps executing on this process's
# session thread and persists its result at natural completion —
# hard-kill scenarios must use a paced tool that outlives their
# observation window.
self._server.should_exit = True
self._thread.join(timeout=20)
with contextlib.suppress(Exception):
self._http.close()
with contextlib.suppress(OSError):
self._sock.close()
# Restore any patched cap defaults.
for meth, defaults in self._orig_defaults:
meth.__defaults__ = defaults
# Restore the storage singleton's load_messages (delay_load knob).
with contextlib.suppress(Exception):
self._patched_storage.load_messages = self._orig_load_messages # type: ignore[method-assign]
def make_listen_socket(port: int, *, sndbuf: int = _DEFAULT_SNDBUF) -> socket.socket:
"""Bound + listening socket the way :class:`RecoveryServer` binds its own.
``SO_REUSEPORT`` on every listener (same process, same uid) is what
lets a restart scenario bind the successor's socket while the prior
node still holds the port the seam behind the gap-free handoff
documented at the ``sock`` parameter. ``SO_SNDBUF`` matches the
server's small send buffer so accepted connections inherit identical
backpressure behavior regardless of which side bound the socket.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, sndbuf)
s.bind(("127.0.0.1", port)) # port=0 -> ephemeral; fixed -> restart reuse
s.listen(128)
return s
def _tcp_ready(port: int, timeout: float) -> bool:
end = time.monotonic() + timeout
while time.monotonic() < end:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
return True
except OSError:
time.sleep(0.05)
return False
def bash_toolcall_script(
call_id: str, command: str, *, finish_reason: str = "tool_calls"
) -> dict[str, Any]:
"""A scripted assistant turn issuing ONE bash tool call."""
return dict(
tool_calls=[{"id": call_id, "name": "bash", "arguments": json.dumps({"command": command})}],
finish_reason=finish_reason,
)
def parallel_bash_script(commands: dict[str, str]) -> dict[str, Any]:
"""A scripted assistant turn issuing SEVERAL bash tool calls at once
(the parallel-pool storm), ``{call_id: command}``.
Each command is prefixed with a no-op ``: <call_id>;`` so the tool
ARGUMENTS are distinct per call while the OUTPUT is unchanged (``:``
ignores its args and prints nothing). Identical-argument parallel
calls otherwise trip the session's repeat-tool-call guard, which
appends a warning to the PERSISTED result only (not the live event)
an orthogonal divergence that would mask the recovery behavior the
convergence assertions test.
"""
return dict(
tool_calls=[
{
"id": cid,
"name": "bash",
"arguments": json.dumps({"command": f": {cid}; {cmd}"}),
}
for cid, cmd in commands.items()
],
finish_reason="tool_calls",
)
def final_text_script(content: str = "done") -> dict[str, Any]:
"""The scripted assistant turn that ends the agent loop (no tools)."""
return dict(content=content, finish_reason="stop")
+76
View File
@@ -0,0 +1,76 @@
"""Recording fake SDK client — captures the kwargs at each provider's seam.
Every provider's ``create_streaming`` assembles its kwargs and calls the
SDK *eagerly* before returning the stream iterator (Anthropic
``client.messages.stream``, OpenAI ``client.chat.completions.create``,
Responses ``client.responses.create/stream``), so driving a provider
against a :class:`RecordingClient` captures the full composed request
payload without a network round-trip.
Shared by the wire-payload golden harness (``test_wire_payload_golden``)
and the effort-ladder parity harness (``test_effort_ladder_wire_parity``)
so both assert against the same capture seam.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
class _EmptyStream:
"""Stand-in for an SDK stream / stream-manager: empty iterable AND no-op CM."""
def __iter__(self) -> Iterator[Any]:
return iter(())
def __enter__(self) -> _EmptyStream:
return self
def __exit__(self, *exc: object) -> None:
return None
class _Seam:
"""Records the kwargs of a single SDK call, returns an empty stream stub."""
def __init__(self, sink: dict[str, Any]) -> None:
self._sink = sink
def __call__(self, **kwargs: Any) -> _EmptyStream:
# Last write wins; only one seam is exercised per provider call.
self._sink["payload"] = kwargs
return _EmptyStream()
class _Completions:
def __init__(self, sink: dict[str, Any]) -> None:
self.create = _Seam(sink)
class _Chat:
def __init__(self, sink: dict[str, Any]) -> None:
self.completions = _Completions(sink)
class _Messages:
def __init__(self, sink: dict[str, Any]) -> None:
self.stream = _Seam(sink)
class _Responses:
def __init__(self, sink: dict[str, Any]) -> None:
self.create = _Seam(sink)
self.stream = _Seam(sink)
class RecordingClient:
"""Fake SDK client exposing every provider's call seam, recording kwargs."""
def __init__(self) -> None:
self.captured: dict[str, Any] = {}
self.messages = _Messages(self.captured)
self.chat = _Chat(self.captured)
self.responses = _Responses(self.captured)
+198 -1
View File
@@ -4,6 +4,9 @@ import asyncio
import contextlib
import logging
import os
import socket
import subprocess
import sys
import threading
import time
from typing import TYPE_CHECKING, Any
@@ -52,8 +55,76 @@ def serve_until_exit(server: Any) -> None:
loop.close()
class _PendingResolver:
"""Race-free drop-in for ``threading.Timer(delay, ui.resolve_approval)``.
``approve_tools`` runs ``_approval_event.clear()`` -> register
``_pending_approval`` -> ``_approval_event.wait(_APPROVAL_WAIT_TIMEOUT)``
(3600s). A *fixed-delay* timer can fire ``resolve_approval``
(``_approval_event.set()``) BEFORE that ``.clear()`` on a slow/loaded
runner, so the set is wiped by the clear and ``approve_tools`` blocks the
full hour -- surfacing as a CI hang. This instead waits until the approval
is actually registered (which happens *after* the clear), then resolves, so
the wakeup can never be lost. ``start()`` / ``cancel()`` mirror
``threading.Timer`` so it drops into existing scaffolding. ``cancel()``
signals the worker to stop and joins it, so a test that errors *before* the
approval registers can't leak the thread or resolve late into a finished
test. ``before`` runs just before resolving -- e.g. to snapshot
pending-state fields the test asserts on.
"""
def __init__(
self,
ui: Any,
*args: Any,
before: Callable[[], None] | None = None,
deadline: float = 10.0,
**kwargs: Any,
) -> None:
self._ui = ui
self._args = args
self._kwargs = kwargs
self._before = before
self._deadline = deadline
self._cancelled = threading.Event()
self._started = False
self._thread = threading.Thread(target=self._run, name="resolve-when-pending", daemon=True)
def _run(self) -> None:
end = time.monotonic() + self._deadline
while time.monotonic() < end:
if self._cancelled.is_set():
return
# getattr (not a bare read) so a UI without _pending_approval can't
# crash the worker into a silent death that leaves approve_tools
# blocked for the full _APPROVAL_WAIT_TIMEOUT.
if getattr(self._ui, "_pending_approval", None) is not None:
if self._before is not None:
self._before()
self._ui.resolve_approval(*self._args, **self._kwargs)
return
time.sleep(0.001)
# Deadline without registration: approve_tools isn't parked on the
# approval event (returned early, or never reached it) -- don't resolve
# into an unknown state; let the test's own assertions speak.
def start(self) -> None:
self._started = True
self._thread.start()
def cancel(self) -> None:
self._cancelled.set()
if self._started:
self._thread.join(timeout=5)
def resolve_when_pending(ui: Any, *args: Any, **kwargs: Any) -> _PendingResolver:
"""Build a race-free approval resolver (see :class:`_PendingResolver`)."""
return _PendingResolver(ui, *args, **kwargs)
if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from turnstone.core.mcp_client import MCPClientManager, StaticServerState
from turnstone.core.mcp_crypto import MCPTokenCipher
@@ -148,6 +219,98 @@ def _seed_static_state(mgr: MCPClientManager, name: str, **overrides: Any) -> St
return state
def _run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any, timeout: float = 10) -> Any:
"""Submit *coro* to *loop*, wait for the result.
The ONE copy shared by the MCP test files four hand-synced copies
had already drifted on the timeout (5s hardcoded vs a 10s default).
The timeout is an upper bound on waiting, not a behavior assertion,
so the most generous variant won the merge.
"""
fut = asyncio.run_coroutine_threadsafe(coro, loop)
return fut.result(timeout=timeout)
def _drain_background(mgr: MCPClientManager, loop: asyncio.AbstractEventLoop) -> None:
"""Deterministically await ``mgr``'s tracked background tasks.
Replaces fixed sleeps for synchronizing with scheduled dead-grant
drops / spawned refreshes: exact, and immune to slow-runner flake.
"""
async def _drain() -> None:
tasks = [t for t in list(mgr._background_tasks) if not t.done()]
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
_run_on_loop(loop, _drain())
def _poll_until(predicate: Callable[[], bool], timeout: float, interval: float = 0.05) -> bool:
"""Poll *predicate* until true or *timeout* elapses — the ONE wait loop.
Shared by the live MCP smoke tests' condition helpers so the
deadline/poll pattern doesn't accrete per-file hand-synced copies.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(interval)
return False
def _free_port() -> int:
"""Grab an ephemeral localhost port for a live-server subprocess.
Shared by the live MCP smoke tests (flaky-server, push-refresh) so
the socket-probe helpers stay in one place instead of drifting per
file.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def _tcp_accepts(port: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
return True
except OSError:
return False
def _wait_tcp_ready(port: int, timeout: float) -> bool:
"""Poll until something accepts TCP on 127.0.0.1:*port* (live tests)."""
return _poll_until(lambda: _tcp_accepts(port), timeout)
def _wait_session_live(mgr: MCPClientManager, name: str, timeout: float) -> bool:
"""Poll until static server *name* has a live session (live tests)."""
def _live() -> bool:
state = mgr._static_servers.get(name)
return state is not None and state.session is not None
return _poll_until(_live, timeout)
def _popen_mcp_server(script_path: Any, port: int) -> subprocess.Popen[bytes]:
"""Start a FastMCP live-server subprocess, streams to DEVNULL.
The shared spawn primitive for the live MCP smoke tests
(flaky-server flap loop, push-refresh) the readiness wait and the
skip-vs-raise-on-failure policy legitimately differ per test and
stay at the call sites. ``sys.executable`` runs the same interpreter,
so a server-side import gap surfaces as a failed TCP wait, not here.
"""
return subprocess.Popen(
[sys.executable, str(script_path), str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def make_oidc_test_config(**overrides: Any) -> OIDCConfig:
"""Build a test ``OIDCConfig`` with sensible defaults.
@@ -267,6 +430,40 @@ def mock_openai_client():
return client
@pytest.fixture
def make_config_store():
"""Factory for a lightweight ConfigStore double.
``make_config_store(**overrides)`` returns an object whose ``.get(key)``
yields the override when present, else the registered SettingDef default
mirroring the real :meth:`ConfigStore.get` fail-open (a bool setting reads
as its ``False`` default on a miss, never ``None``). Shared by the
``server.require_project`` gate / advisory tests.
"""
_unset = object()
def _make(**overrides: Any) -> Any:
from turnstone.core.settings_registry import SETTINGS
class _ConfigStoreDouble:
def get(self, key: str, default: Any = _unset) -> Any:
# Mirror ConfigStore.get precedence exactly: cache (overrides)
# first, then a caller-supplied default, then the registry
# default, then None — so a reused caller passing an explicit
# default for an unset key gets the same value production would.
if key in overrides:
return overrides[key]
if default is not _unset:
return default
defn = SETTINGS.get(key)
return defn.default if defn else None
return _ConfigStoreDouble()
return _make
@pytest.fixture(autouse=True)
def _clear_policy_cache():
"""Drop the in-process tool-policy cache between tests.
+36
View File
@@ -0,0 +1,36 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "",
"provider_content": null,
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Oslo\"}",
"name": "get_weather"
},
"id": "synth-id-0",
"type": "function"
}
]
},
"scenario": "blank_id_tools",
"ui_events": [
[
"thinking_stop",
""
],
[
"stream_end",
""
]
]
}
@@ -0,0 +1,40 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "Before tools",
"provider_content": null,
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Nice\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
}
]
},
"scenario": "combined_content_tools_finish",
"ui_events": [
[
"thinking_stop",
""
],
[
"content",
"Before tools"
],
[
"stream_end",
""
]
]
}
+35
View File
@@ -0,0 +1,35 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "Redac",
"provider_content": null,
"tool_calls": null
},
"scenario": "content_filter",
"ui_events": [
[
"thinking_stop",
""
],
[
"content",
"Redac"
],
[
"error",
"Warning: response blocked by content filter."
],
[
"stream_end",
""
]
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "Hello world.",
"provider_content": null,
"tool_calls": null
},
"scenario": "content_only",
"ui_events": [
[
"thinking_stop",
""
],
[
"content",
"Hello world."
],
[
"stream_end",
""
]
]
}
@@ -0,0 +1,23 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "",
"provider_content": null,
"tool_calls": null
},
"scenario": "finish_only_no_content",
"ui_events": [
[
"stream_end",
""
]
]
}
@@ -0,0 +1,39 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "Answer with sources.",
"provider_content": null,
"tool_calls": null
},
"scenario": "info_postfinish_footer",
"ui_events": [
[
"thinking_stop",
""
],
[
"content",
"Answer w"
],
[
"info",
"Sources:\n- example.com/page"
],
[
"content",
"ith sources."
],
[
"stream_end",
""
]
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "Seals are pinnipeds.",
"provider_content": null,
"tool_calls": null
},
"scenario": "info_prefinish",
"ui_events": [
[
"thinking_stop",
""
],
[
"info",
"[Searching: pinniped taxonomy]"
],
[
"content",
"Seals ar"
],
[
"content",
"e pinnipeds."
],
[
"stream_end",
""
]
]
}
@@ -0,0 +1,43 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "Partial answer",
"provider_content": null,
"tool_calls": null
},
"scenario": "length_with_tools",
"ui_events": [
[
"thinking_stop",
""
],
[
"content",
"Pa"
],
[
"content",
"rtial answer"
],
[
"error",
"Warning: response truncated (hit 4096 token limit). Use --max-tokens to increase, or /compact to free context."
],
[
"error",
"Discarding partial tool calls from truncated response."
],
[
"stream_end",
""
]
]
}
@@ -0,0 +1,31 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 0,
"prompt_tokens": 11,
"total_tokens": 11
},
"raised": null,
"result": {
"content": "Half an ans",
"provider_content": null,
"tool_calls": null
},
"scenario": "no_finish_clean_exhaust",
"ui_events": [
[
"thinking_stop",
""
],
[
"content",
"Half an ans"
],
[
"stream_end",
""
]
]
}
@@ -0,0 +1,36 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "Blocked.",
"provider_content": [
{
"text": "captured",
"type": "reasoning_text"
}
],
"tool_calls": null
},
"scenario": "provider_blocks_on_terminal",
"ui_events": [
[
"thinking_stop",
""
],
[
"content",
"Blocked."
],
[
"stream_end",
""
]
]
}
@@ -0,0 +1,44 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "Answer.",
"provider_content": [
{
"text": "think a think b",
"type": "reasoning_text"
}
],
"tool_calls": null
},
"scenario": "reasoning_then_content",
"ui_events": [
[
"thinking_stop",
""
],
[
"reasoning",
"think a"
],
[
"reasoning",
" think b"
],
[
"content",
"Answer."
],
[
"stream_end",
""
]
]
}
@@ -0,0 +1,40 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "\n\nAnswer",
"provider_content": [
{
"text": "plan",
"type": "reasoning_text"
}
],
"tool_calls": null
},
"scenario": "think_tags_split_across_chunks",
"ui_events": [
[
"thinking_stop",
""
],
[
"reasoning",
"plan"
],
[
"content",
"\n\nAnswer"
],
[
"stream_end",
""
]
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"cancelled_partial": null,
"last_usage": {
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"completion_tokens": 7,
"prompt_tokens": 11,
"total_tokens": 18
},
"raised": null,
"result": {
"content": "Calling.",
"provider_content": null,
"tool_calls": [
{
"function": {
"arguments": "{\"city\": \"Paris\"}",
"name": "get_weather"
},
"id": "call_1",
"type": "function"
}
]
},
"scenario": "tools_simple",
"ui_events": [
[
"thinking_stop",
""
],
[
"content",
"Calling."
],
[
"stream_end",
""
]
]
}
@@ -0,0 +1,77 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris and London?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
},
{
"id": "call_2",
"input": {
"city": "London"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
},
{
"content": "Tool execution was cancelled. Outcome UNKNOWN — this call may have begun executing before the generation was stopped; do not assume it did not run, and reconcile before re-issuing it.",
"is_error": true,
"tool_use_id": "call_2",
"type": "tool_result"
},
{
"text": "Actually, never mind London.",
"type": "text"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,32 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": [
{
"text": "What's in this image?",
"type": "text"
},
{
"source": {
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"media_type": "image/png",
"type": "base64"
},
"type": "image"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b"
}
@@ -0,0 +1,69 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": [
{
"signature": "sig-abc",
"thinking": "The user wants weather.",
"type": "thinking"
},
{
"text": "Let me check.",
"type": "text"
},
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "Tool execution was cancelled. Outcome UNKNOWN — this call may have begun executing before the generation was stopped; do not assume it did not run, and reconcile before re-issuing it.",
"is_error": true,
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,68 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Think about the weather.",
"role": "user"
},
{
"content": [
{
"signature": "sig-abc",
"thinking": "The user wants weather.",
"type": "thinking"
},
{
"text": "Let me check.",
"type": "text"
},
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,62 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Run the deploy.",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {},
"name": "deploy",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "deployed",
"tool_use_id": "call_1",
"type": "tool_result"
},
{
"text": "Great, what's next?",
"type": "text"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"system": "Output-guard: deploy output looked clean.",
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,32 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Hi there.",
"role": "user"
},
{
"content": [
{
"text": "Hello! How can I help?",
"type": "text"
}
],
"role": "assistant"
},
{
"content": "What's the weather in Paris?",
"role": "user"
}
],
"model": "qwen3.6-27b"
}
@@ -0,0 +1,68 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "18C, clear.",
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
},
{
"content": [
{
"text": "It's 18C and clear in Paris.",
"type": "text"
}
],
"role": "assistant"
}
],
"model": "qwen3.6-27b",
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -0,0 +1,60 @@
{
"cache_control": {
"type": "ephemeral"
},
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"reasoning_effort": "high"
}
},
"max_tokens": 4096,
"messages": [
{
"content": "Weather in Paris?",
"role": "user"
},
{
"content": [
{
"id": "call_1",
"input": {
"city": "Paris"
},
"name": "get_weather",
"type": "tool_use"
}
],
"role": "assistant"
},
{
"content": [
{
"content": "Tool execution was cancelled. Outcome UNKNOWN — this call may have begun executing before the generation was stopped; do not assume it did not run, and reconcile before re-issuing it.",
"is_error": true,
"tool_use_id": "call_1",
"type": "tool_result"
}
],
"role": "user"
}
],
"model": "qwen3.6-27b",
"tools": [
{
"description": "Look up the weather for a city.",
"input_schema": {
"properties": {
"city": {
"type": "string"
}
},
"required": [
"city"
],
"type": "object"
},
"name": "get_weather"
}
]
}
@@ -51,9 +51,6 @@
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"
@@ -23,9 +23,6 @@
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"
@@ -43,9 +43,6 @@
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"
@@ -42,9 +42,6 @@
}
],
"model": "claude-sonnet-4-6",
"output_config": {
"effort": "medium"
},
"temperature": 1.0,
"thinking": {
"type": "adaptive"

Some files were not shown because too many files have changed in this diff Show More