mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d174cd71c | |||
| df573b7314 | |||
| 3c7a3c1375 | |||
| 41e7d5b7d7 | |||
| ca23f2876c | |||
| a9898fdd6c | |||
| c71cc749d9 | |||
| 2fb80cb88f | |||
| 2dd0688d45 | |||
| 848f123985 | |||
| 76241ab703 | |||
| 409875e296 | |||
| 8e4f32c93a | |||
| 3e4c1931a1 | |||
| df7926215b | |||
| f583fb06db | |||
| 4bca60c56c | |||
| 80b8997b88 | |||
| bf9299de1a | |||
| fbfd170ca6 | |||
| 71c34839d9 | |||
| f923351953 | |||
| a7cab83dd1 | |||
| b28e8bac80 | |||
| 48f4c41442 | |||
| 7f50fbefad | |||
| b8addd55c0 | |||
| f585c47b7d | |||
| ee3a0297ea | |||
| c6e5794125 | |||
| 85b62860b2 | |||
| 74cf4e92aa | |||
| 6572b53c89 | |||
| 7f1329d3b0 | |||
| 5004858032 | |||
| de60127c45 | |||
| c7e0358aaf | |||
| bbadd00ac0 | |||
| 8dd356b7e6 | |||
| 77cb76c006 | |||
| ca7958329a | |||
| 65eaacb341 | |||
| 9837214414 | |||
| 2b0b1cf73e | |||
| 7263b31536 | |||
| 6b6c220986 | |||
| 65d1552ffa | |||
| 9f54a97cc6 |
@@ -0,0 +1,46 @@
|
||||
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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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 *)'
|
||||
|
||||
@@ -7,7 +7,9 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
# Never cancel mid-push: an interrupted multi-tag push can leave the
|
||||
# registry with a partial tag set (e.g. :latest moved, :stable not).
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -19,15 +21,24 @@ env:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
# Same gate as publish.yml: workflow_run fires for every CI completion
|
||||
# (including fork and same-repo PR runs) with this repo's token and
|
||||
# packages:write. Only same-repo tag pushes may publish images; CI's
|
||||
# push trigger matches main/stable/* and v* tags, so a head_branch
|
||||
# starting with "v" is necessarily a tag run.
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
# The docker build only reads the tree; keep the token out of it.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
id: tag
|
||||
@@ -72,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -7,7 +7,9 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: publish-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
# Never cancel a publish mid-upload: a half-uploaded release (sdist up,
|
||||
# wheel missing) cannot be re-run cleanly because PyPI rejects duplicates.
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -15,7 +17,16 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
# workflow_run fires for EVERY CI completion — including CI runs for
|
||||
# pull_requests from forks — and always executes here with this repo's
|
||||
# secrets, tokens, and the pypi environment. Gate to same-repo tag
|
||||
# pushes only: CI's push trigger matches branches main/stable/* and
|
||||
# tags v*, so a head_branch starting with "v" is necessarily a tag run.
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v')
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
@@ -23,6 +34,9 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
# python -m build executes the tree's build backend; don't leave
|
||||
# the contents:write token sitting in .git/config while it runs.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
id: tag
|
||||
|
||||
@@ -25,18 +25,39 @@ permissions:
|
||||
|
||||
jobs:
|
||||
vendor-js:
|
||||
if: github.actor == 'renovate[bot]' || github.event_name == 'workflow_dispatch'
|
||||
# Same-repo PRs only: this job checks out the PR head and pushes to it
|
||||
# with contents:write, so it must never act on a fork's branch.
|
||||
# Gate on the PR author (immutable), not github.actor (names whoever
|
||||
# caused the latest event, which can be someone else re-running it).
|
||||
if: >-
|
||||
(github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.user.login == 'renovate[bot]' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve PR head ref
|
||||
id: ref
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Branch names may contain shell metacharacters; pass via env,
|
||||
# never interpolate ${{ }} into the script body.
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
ref=$(gh pr view "${{ inputs.pr_number }}" --repo "${{ github.repository }}" --json headRefName -q .headRefName)
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
# The dispatch input is an arbitrary PR number; refuse fork PRs.
|
||||
# A fork's headRefName is a bare branch name that may collide
|
||||
# with a branch in this repo, and checkout+push would then hit
|
||||
# that unrelated branch ("same-repo PRs only" applies here too).
|
||||
pr_json=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefName,isCrossRepository)
|
||||
if [[ "$(jq -r '.isCrossRepository' <<< "$pr_json")" != "false" ]]; then
|
||||
echo "::error::PR #${PR_NUMBER} head is not a branch in this repository; refusing to complete it."
|
||||
exit 1
|
||||
fi
|
||||
ref=$(jq -r '.headRefName' <<< "$pr_json")
|
||||
else
|
||||
ref="${{ github.head_ref }}"
|
||||
ref="$HEAD_REF"
|
||||
fi
|
||||
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
+1
-1
@@ -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.24 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /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
|
||||
|
||||
+69
-31
@@ -10,7 +10,9 @@ Most descriptions of an agent framework are a feature list. This is an attempt a
|
||||
|
||||
*Informal.* A harness is a **stopped, deterministically-controlled Markov process on task-state, closed around a stopped autoregressive process on context-space, driven by a learned model kernel** — a deterministic controller in closed loop with a stochastic learned plant.
|
||||
|
||||
*Formal — the objects.* A harness is a tuple $\mathcal{H} = (\mathcal{S}, \mathcal{C}, \mathcal{Y}, \mathcal{A}, \mathcal{E}, \pi, M_W, \gamma, Q_E, \rho, H, H_{\mathrm{ok}}, B)$ over **standard Borel** spaces (concretely: the *controlled* state is standard Borel by construction — token sequences, finite config maps, bounded counters and ledgers, finite tuples of real vectors — and the model/environment coordinates are inherited as such whenever they serialize to a Polish space; the assumption fails only if a coordinate is itself a measure or an uncountable product, which this construction avoids): a deterministic lowering $\pi:\mathcal{S}\to\mathcal{C}$; a stochastic model-run kernel $M_W(c, dy)$ into a readout space $\mathcal{Y}$ (which includes the parse-failure $\bot$, so $M_W$ and $\gamma$ are total over it); a deterministic **authorization gate** $\gamma:\mathcal{S}\times\mathcal{Y}\to\mathcal{A}_{\bot}$ that parses/validates the model output into an authorized action in $\mathcal{A}$ or rejects it as $\bot$; a stochastic environment/tool kernel $Q_E:\mathcal{S}\times\mathcal{A}_{\bot}\rightsquigarrow\mathcal{E}$ on the authorized action (rejection included, with $Q_E(s,\bot,\cdot)=\delta_{e_0}$ for a distinguished no-op response $e_0\in\mathcal{E}$); and a deterministic verify-and-fold-back map $\rho:\mathcal{S}\times\mathcal{Y}\times\mathcal{A}_{\bot}\times\mathcal{E}\to\mathcal{S}$.
|
||||
*In plain terms.* The **harness** is the whole governed loop: a deterministic **shell** you write — build the prompt, authorize an action, fold the response back into state — wrapped around a black-box stochastic model kernel (the **plant**, $M_W$) and the environment its actions touch, looped until it halts in $H$. The shell is deterministic, $M_W$ is not, and everything below makes that split precise.
|
||||
|
||||
*Formal — the objects.* A harness is a tuple $\mathcal{H} = (\mathcal{S}, \mathcal{C}, \mathcal{Y}, \mathcal{A}, \mathcal{E}, \pi, M_W, \gamma, Q_E, \rho, H, H_{\mathrm{ok}}, B)$ over **standard Borel** spaces (concretely: the *controlled* state is standard Borel by construction — token sequences, finite config maps, bounded counters and ledgers, finite tuples of real vectors — and the model/environment coordinates are inherited as such whenever they serialize to a Polish space; the assumption is roomier than it looks — even a belief-state coordinate valued in $\mathcal{P}(X)$ survives, since $\mathcal{P}(X)$ is Polish for Polish $X$ — and fails only for a genuinely non-separable coordinate, an uncountable product $\sigma$-algebra being the canonical hazard, which this construction avoids): a deterministic lowering $\pi:\mathcal{S}\to\mathcal{C}$; a stochastic model-run kernel $M_W(c, dy)$ into a readout space $\mathcal{Y}$ (which includes the parse-failure $\bot$, so $M_W$ and $\gamma$ are total over it); a deterministic **authorization gate** $\gamma:\mathcal{S}\times\mathcal{Y}\to\mathcal{A}_{\bot}$ that validates the model's parsed readout into an authorized action in $\mathcal{A}$ or rejects it as $\bot$ (parsing itself lives inside $M_W$ — realized as the readout $R$ of the specialization below); a stochastic environment/tool kernel $Q_E:\mathcal{S}\times\mathcal{A}_{\bot}\rightsquigarrow\mathcal{E}$ on the authorized action (rejection included, with $Q_E(s,\bot,\cdot)=\delta_{e_0}$ for a distinguished no-op response $e_0\in\mathcal{E}$); and a deterministic verify-and-fold-back map $\rho:\mathcal{S}\times\mathcal{Y}\times\mathcal{A}_{\bot}\times\mathcal{E}\to\mathcal{S}$.
|
||||
|
||||
*Terminal structure.* The terminal set is an absorbing halt set $H\subseteq\mathcal{S}$ (the daemon "ready-state" recurrence of the note below is a separate, non-absorbing object) with accepting subset $H_{\mathrm{ok}}\subseteq H$; separately, a bad set $B\subseteq\mathcal{S}$ ($B\cap H_{\mathrm{ok}}=\varnothing$) marks the unsafe states for reach-avoid, possibly entered before any halt; hitting times are $\tau_A=\inf\{n\ge 0:s_n\in A\}$, and $\tau_H$ is a stopping time for the natural filtration.
|
||||
|
||||
@@ -20,17 +22,17 @@ $$T(s, A) = \int_{\mathcal{Y}}\!\int_{\mathcal{E}} \mathbf{1}_A\!\big(\rho(s, y,
|
||||
|
||||
and the harness runs $s_{n+1} \sim T(s_n)$ from an initial $s_0 \sim \mu_0$ until $\tau_H = \inf\{n : s_n \in H\}$. Because $\pi, \gamma, \rho, H$ are deterministic they contribute no integration variable of their own — they appear as measurable transformations inside the integrand (the pushforward), not literally outside it — so the controller injects no randomness, and every coin is inherited from $M_W$ and $Q_E$. (The earlier shorthand $T = \rho \circ (M_W \circ \pi, E)$ is suggestive but ill-typed — $M_W$ returns a *law*, while $\rho$ consumes a *sample* together with the prior state $s$; the integral is what the shorthand meant.)
|
||||
|
||||
*Fail-closed.* The gate $\gamma$ is what makes **fail-closed** a property, not just a name: model output is an *untrusted proposal*, and $\gamma(s,y)=\bot$ forces a no-op environment response ($Q_E(s,\bot,\cdot)=\delta_{e_0}$) — so a malformed or unauthorized tool call is rejected *before* it can act, not validated after its side effects have landed. Fail-closed is then the property that a rejected proposal causes *no unauthorized side effect* and lands in a **safe, non-bad** set ($\rho(s,y,\bot,e_0)\notin B$): a non-accepting terminal $H\setminus H_{\mathrm{ok}}$ in the strict case, or a safe non-terminal state when the spec retries. And $\rho$ must validate the tool *response* $e$, not only the proposal that $\gamma$ already gated: a malformed or adversarial $Q_E$ output is caught at fold-back, not just at the gate. But response-validation has a hard limit: $\rho$ can reject a bad tool *response*, yet it cannot undo side effects an *authorized* action already caused — so $\gamma$, not $\rho$, is the last line before irreversible effects, and anything irreversible must be gated at authorization. The boundary is also only real if raw model output reaches *no* sink — tool, logger, browser, or remote call — before $\gamma$; any pre-authorization escape bypasses the gate. The user-visible final response and any logging are themselves effects: either an authorized action through $\gamma$, or emitted only after an accepted halt in $H_{\mathrm{ok}}$.
|
||||
*Fail-closed.* The gate $\gamma$ is what makes **fail-closed** a property, not just a name: model output is an *untrusted proposal*, and $\gamma(s,y)=\bot$ forces a no-op environment response ($Q_E(s,\bot,\cdot)=\delta_{e_0}$) — so a malformed or unauthorized tool call is rejected *before* it can act, not validated after its side effects have landed. Fail-closed is then the property that a rejected proposal causes *no unauthorized side effect* and lands in a **safe, non-bad** set ($\rho(s,y,\bot,e_0)\notin B$): a non-accepting terminal $H\setminus H_{\mathrm{ok}}$ in the strict case, or a safe non-terminal state when the spec retries. And $\rho$ must validate the tool *response* $e$, not only the proposal that $\gamma$ already gated: a malformed or adversarial response $e$ is caught at fold-back, not just at the gate. But response-validation has a hard limit: $\rho$ can reject a bad tool *response*, yet it cannot undo side effects an *authorized* action already caused — so $\gamma$, not $\rho$, is the last line before irreversible effects, and anything irreversible must be gated at authorization. The boundary is also only real if raw model output reaches *no* sink — tool, logger, browser, or remote call — before $\gamma$; any pre-authorization escape bypasses the gate. The user-visible final response and any logging are themselves effects, and the rule binds *model-authored* bytes: they reach a sink either as an authorized action through $\gamma$, or only after an accepted halt in $H_{\mathrm{ok}}$. Shell-*templated* text — a refusal notice, a cancellation report reading the ledger — is controller output, outside $\gamma$'s jurisdiction, and may accompany any halt (a template that *interpolates* model-authored fragments inherits the model's label — the appendix's meet rule — and those bytes are gated like any others); the invariant is that raw model text never reaches a sink ungated, not that failed runs die silent.
|
||||
|
||||
*The harness invariants.* These are the invariants that make $\mathcal{H}$ a *harness* and not merely a controlled Markov process with a learned kernel inside: the model sees only $\mathcal{C}$, never full $\mathcal{S}$; its outputs are proposals, not actions; a deterministic capability boundary $\gamma$ gates every side effect; and the *terminal* set $H$ splits into accepting ($H_{\mathrm{ok}}$) and non-accepting ($H\setminus H_{\mathrm{ok}}$ — safe refusals outside $B$, and wrong or bad halts possibly in $B$), while the bad set $B$ is a *separate* unsafe set — possibly absorbing, possibly entered mid-run before any halt — against which $\tau_B$ is measured for reach-avoid.
|
||||
*The harness invariants.* These are the invariants that make $\mathcal{H}$ a *harness* and not merely a controlled Markov process with a learned kernel inside: the model sees only $\mathcal{C}$, never full $\mathcal{S}$; its outputs are proposals, not actions; a deterministic capability boundary $\gamma$ gates every side effect; and the *terminal* set $H$ splits into accepting ($H_{\mathrm{ok}}$) and non-accepting ($H\setminus H_{\mathrm{ok}}$ — safe refusals outside $B$, and wrong or bad halts possibly in $B$), while the bad set $B$ is a *separate* unsafe set — possibly absorbing, possibly entered mid-run before any halt — against which $\tau_B$ is measured for reach-avoid. Two notes keep the invariants honest. They are *signature*, not strength: a $\gamma$ that authorizes everything still satisfies the tuple, as a trivial group satisfies the group axioms — the definition admits degenerate harnesses, and fail-closed, provenance isolation, and the certificates below are properties a particular harness *earns*, not gifts of the signature. And the first invariant has a sharper, two-sided form: $\pi$ is the *only* channel from state to model — the confidentiality floor lives at what $\pi$ must never lower (credentials, other principals' data) — exactly as $\gamma$ is the only channel from model output to effect, where the injection bounds live; exfiltration is therefore cut at either chokepoint, never lowered or never emitted (the gate refusing the read whose URL is the payload is the emission-side cut). One chokepoint out of the state, one into the world; a bypass of either is the same bug with the sign flipped.
|
||||
|
||||
*Beyond the stationary kernel.* This displayed $T$ is the time-homogeneous, fixed-kernel case; for nonstationary or adversarial environments, replace $Q_E$ with a time-indexed kernel $Q_{E,n}$ — or an admissible family of kernels, or an adversary's policy — over which the robust certificate (the minimax form under *The limit*) quantifies. If that adversary conditions on history rather than only the current $(s, y)$, the history must itself live in $s$ — otherwise the object is a Markov *game* requiring further augmentation, not a Markov chain.
|
||||
*Beyond the stationary kernel.* This displayed $T$ is the time-homogeneous, fixed-kernel case; for nonstationary or adversarial environments, replace $Q_E$ with a time-indexed kernel $Q_{E,n}$ — or an admissible family of kernels, or an adversary's policy — over which the robust certificate (the minimax form under *The limit*) quantifies. If that adversary conditions on history rather than only the current $(s, y)$, the history must itself live in $s$ — otherwise the object is a Markov *game* requiring further augmentation, not a Markov chain. And nonstationarity is not the environment's monopoly: a provider retraining or re-serving under a fixed endpoint name is a nonstationary $M_{W,n}$ — the table places model version *in* $s$ precisely so a version bump is a visible state change — and any measured surrogate (the $\delta$ of *The limit*) is calibrated against one kernel and dies with the bump; the dashboard must be keyed to the kernel it measured.
|
||||
|
||||
*The inner kernel.* $M_W$ is itself a stopped process, and for a decoder-only transformer it is implemented as
|
||||
|
||||
$$M_W(c, \cdot) = \mathrm{Law}\big(R(z_{\tau})\big), \quad z_t = (c_t, b_t, m_t), \quad v \sim K_W(c_t, \cdot), \quad K_W(c, v) = (U \circ \Phi_W \circ \mathrm{Emb})(c)[v], \quad c_{t+1} = \mathrm{suffix}_{\le L}(c_t\!\cdot\! v),\ \ b_{t+1} = b_t\!\cdot\! v,\ \ m_{t+1} = \mathsf{step}(m_t, v),\ \ \tau=\inf\{t:m_t\in\mathrm{Stop}\}.$$
|
||||
|
||||
with the layer stack $\Phi_W$ on the residual stream as the (loosely) "manifold" core — formally just the learned high-dimensional residual-stream transformation, with manifold-proper reserved for the frontier. The inner state $z_t=(c_t,b_t,m_t)$ separates the model-visible window $c_t$ (the $\le L$ slice that slides) from the untruncated output buffer $b_t$ (the transcript the readout actually consumes, so truncation never loses it) and the parser/stop state $m_t$ (parser state, a token counter, and a clock, so the cap and timeout are functions of it), updated $m_{t+1}=\mathsf{step}(m_t,v)$, whose stop set $\mathrm{Stop}$ — EOS emitted, max-token cap, timeout, or parse-failure $\bot$ — forces $\tau=\inf\{t:m_t\in\mathrm{Stop}\}$ finite, making $M_W$ a genuine *probability* kernel rather than a sub-probability one completed by a cemetery output. The readout is total, $R : \mathcal{Z} \to \mathcal{Y}$ — a parsed tool-call, answer, or transcript, returning the parse-failure $\bot\in\mathcal{Y}$ when parsing fails; crucially $R$ is a *syntactic, verified* readout (parsing and extraction), not a semantic solver, or the $L$-wall below is void — arbitrary computation could hide in $R$ off the $\le L$ window — so $M_W(c, \cdot) = R_{\sharp}\,\mathrm{Law}(z_{\tau})$, the pushforward of the stopped-state law along $R$ (equivalently $M_W(c, A_Y) = \Pr[R(z_{\tau}) \in A_Y \mid z_0 = (c,\varnothing,m_0)]$ for a measurable $A_Y\subseteq\mathcal{Y}$); the no-truncation special case takes $\mathcal{Y}=\mathcal{C}$ with $R(c,b,m)=c$ (the window is the whole transcript), reading $c_{\tau}$ directly. The $\bot$ branch is exactly what $\gamma$ rejects fail-closed. This is a **specialization, not part of the definition**: a harness wrapped around a black-box API is still a harness, and $M_W$ may be any learned kernel. Where the weights are open, the geometry of $\Phi_W$ is where the substrate's continuity lives, and several downstream claims lean on it — but the definition does not.
|
||||
with the layer stack $\Phi_W$ on the residual stream as the (loosely) "manifold" core — formally just the learned high-dimensional residual-stream transformation, with manifold-proper reserved for the frontier. The inner state $z_t=(c_t,b_t,m_t)$ separates the model-visible window $c_t$ (the $\le L$ slice that slides) from the untruncated output buffer $b_t$ (the transcript the readout actually consumes, so truncation never loses it) and the parser/stop state $m_t$ (parser state, a token counter, and a clock, so the cap and timeout are functions of it), updated $m_{t+1}=\mathsf{step}(m_t,v)$, whose stop set $\mathrm{Stop}$ — EOS emitted, max-token cap, timeout, or parse-failure $\bot$ — forces $\tau=\inf\{t:m_t\in\mathrm{Stop}\}$ finite, making $M_W$ a genuine *probability* kernel rather than a sub-probability one completed by a cemetery output. (One honesty note on the clock: a token-count cap is a deterministic function of the run, but a *wall-clock* timeout imports infrastructure noise — server load, batching, congestion — into the kernel's coin; legitimate, a kernel may carry any randomness, but it makes the displayed $M_W$ the model *plus its serving substrate*, and the determinism audit under *How this could be wrong* must hold the clock fixed along with the samples.) The readout is total, $R : \mathcal{Z} \to \mathcal{Y}$ — a parsed tool-call, answer, or transcript, returning the parse-failure $\bot\in\mathcal{Y}$ when parsing fails; crucially $R$ is a *syntactic, verified* readout (parsing and extraction), not a semantic solver, or the $L$-wall below is void — arbitrary computation could hide in $R$ off the $\le L$ window — so $M_W(c, \cdot) = R_{\sharp}\,\mathrm{Law}(z_{\tau})$, the pushforward of the stopped-state law along $R$ (equivalently $M_W(c, A_Y) = \Pr[R(z_{\tau}) \in A_Y \mid z_0 = (c,\varnothing,m_0)]$ for a measurable $A_Y\subseteq\mathcal{Y}$); the no-truncation special case takes $\mathcal{Y}=\mathcal{C}$ with $R(c,b,m)=c$ (the window is the whole transcript), reading $c_{\tau}$ directly. The $\bot$ branch is exactly what $\gamma$ rejects fail-closed. This is a **specialization, not part of the definition**: a harness wrapped around a black-box API is still a harness, and $M_W$ may be any learned kernel. Where the weights are open, the geometry of $\Phi_W$ is where the substrate's continuity lives, and several downstream claims lean on it — but the definition does not.
|
||||
|
||||
Two stopped processes, nested: **deterministic control over stochastic dynamics over a learned kernel.** Both loops are hitting-time processes; *some* harnesses additionally read the halt set as a fixpoint or acceptance condition — iterative refinement to self-consistency is the genuine fixpoint case, while EOS, length, and tool-call syntax are not convergence. Neither loop settles because you asked it to. (The clean inner-then-outer nesting assumes tool calls fall *between* model runs; streaming or mid-generation tool calls interleave the two loops and need a finer state machine — the nesting is then an idealization.)
|
||||
|
||||
@@ -40,7 +42,7 @@ Two stopped processes, nested: **deterministic control over stochastic dynamics
|
||||
|---|---|
|
||||
| $\mathcal{H}$ | the harness — the whole controlled system, *not* the model |
|
||||
| $s \in \mathcal{S}$ | task-state: IR / dialect stack, tool results, plan, counters, **and every mutable interface variable** (model/tool versions, permissions, retrieved context) — only Markov *after* that augmentation |
|
||||
| $\mathcal{C},\ \mathcal{Y},\ \mathcal{A},\ \mathcal{E}$ | the **context / readout / action / effect spaces** — model-visible context $\mathcal{C}$, model readout $\mathcal{Y}$ (incl. the parse-failure $\bot$), authorized actions $\mathcal{A}$ (with $\mathcal{A}_{\bot} = \mathcal{A}\cup\{\bot\}$), and tool/environment effects $\mathcal{E}$ |
|
||||
| $\mathcal{C},\ \mathcal{Y},\ \mathcal{A},\ \mathcal{E}$ | the **context / readout / action / effect spaces** — model-visible context $\mathcal{C}$, model readout $\mathcal{Y}$ (incl. the parse-failure $\bot$), authorized actions $\mathcal{A}$ (with $\mathcal{A}_\bot = \mathcal{A}\cup\{\bot\}$), and tool/environment effects $\mathcal{E}$ |
|
||||
| $\pi : \mathcal{S} \to \mathcal{C}$ | **lowering** — prompt construction, dialect lowering, effective-program selection (deterministic) |
|
||||
| $M_W(c, dy)$ | the **model-run kernel** (inner solver) — a stopped autoregressive process; $\Phi_W$ is the residual-stream ("manifold") core in the transformer case |
|
||||
| $Q_E(s, a, de)$ | the **environment/tool kernel** on the authorized action $a\in\mathcal{A}_{\bot}$ (with $Q_E(s,\bot,\cdot)=\delta_{e_0}$, the no-op $e_0$) — tool effects, API responses, the world (possibly adversarial) |
|
||||
@@ -48,7 +50,7 @@ Two stopped processes, nested: **deterministic control over stochastic dynamics
|
||||
| $H,\ \tau_H$ | the **halt set** (absorbing) and the outer **halting time** — a hitting-time process, not a single pass |
|
||||
| $H_{\mathrm{ok}},\ B$ | the **accepting halts** $H_{\mathrm{ok}}\subseteq H$ (correct, successful terminals) and the **bad set** $B$ — unsafe states for reach-avoid ($B\cap H_{\mathrm{ok}}=\varnothing$), *separate* from $H$ and possibly entered mid-run before any halt |
|
||||
|
||||
The structural fact that earns the word *controller*: $\pi$, $\gamma$, $\rho$, and the halt test are **deterministic** (and the readout $R$ too, where the transformer specialization is in play), so $\mathcal{H}$ injects no randomness of its own. Every coin is inherited from $M_W$ and $Q_E$. This split — deterministic code around a stochastic oracle — wears two names. In control-theory terms it is **controller vs. plant**: the controller is those deterministic maps; the **plant** is the learned kernel $M_W$, *plant* in its exact sense — the element with its own dynamics you steer but do not author. In engineering terms it is **shell vs. plant**: the **shell** is the entire deterministic outer harness — the control logic *plus* the external memory and tools it administers (the files, databases, vector stores below) — of which the controller is just the control-logic slice. So *shell : plant :: the part you write : the part you don't*; $M_W$ is the only thing on the right, while the environment $Q_E$ is the world the actions meet — a disturbance into the loop, not the plant. This determinism is *conditional* — on versioned code, configuration, model endpoint, and tool interfaces; any retry, timeout, race, or randomized routing that escapes that conditioning must be modeled explicitly as part of $Q_E$ or the controller, not waved away. The displayed $M_W(c)$ likewise freezes endpoint, version, and sampler; a routing or config change is a state-indexed kernel $M_{\kappa(s)}$ or folds into $K_C$ — the kernel must not silently depend on config the table places in $s$. More generally, control may itself be stochastic — a controller kernel $K_C(s, dc)$ over routing, sampled retries, ensemble votes, learned routers — of which the deterministic $\pi, \gamma, \rho, H$ are the Dirac special case. That case is the one worth wanting: it localizes every coin to $M_W$ and $Q_E$ and keeps the controller/plant split clean. Where control is genuinely stochastic the split does not break, it widens — fold $K_C$ into the kernel and the certificate quantifies over its randomness too.
|
||||
The structural fact that earns the word *controller*: $\pi$, $\gamma$, $\rho$, and the halt test are **deterministic** (and the readout $R$ too, where the transformer specialization is in play), so $\mathcal{H}$ injects no randomness of its own. Every coin is inherited from $M_W$ and $Q_E$. This split — deterministic code around a stochastic oracle — wears two names. In control-theory terms it is **controller vs. plant**: the controller is those deterministic maps; the **plant** is the learned kernel $M_W$, *plant* in its exact sense — the element with its own dynamics you steer but do not author. In engineering terms it is **shell vs. plant**: the **shell** is the entire deterministic outer harness — the control logic *plus* the external memory and tools it administers (the files, databases, vector stores below) — of which the controller is just the control-logic slice. So *shell : plant :: the part you write : the part you don't*; $M_W$ is the only thing on the right, while the environment $Q_E$ is the world the actions meet — a disturbance into the loop, not the plant. (A reader from reinforcement learning or classical control will make the opposite assignment — environment as plant, policy as controller; the inversion is deliberate: in harness engineering the element you are trying to make behave is the model, and the world is what pushes back on the attempt.) This determinism is *conditional* — on versioned code, configuration, model endpoint, and tool interfaces, and on *single-run sequencing*: concurrent runs sharing authorization state re-open a gap the per-run object cannot see (taken up under *Gate placement* in the appendix); any retry, timeout, race, or randomized routing that escapes that conditioning must be modeled explicitly as part of $Q_E$ or the controller, not waved away. The displayed $M_W(c)$ likewise freezes endpoint, version, and sampler; a routing or config change is a state-indexed kernel $M_{\kappa(s)}$ or folds into $K_C$ — the kernel must not silently depend on config the table places in $s$. More generally, control may itself be stochastic — a controller kernel $K_C(s, dc)$ over routing, sampled retries, ensemble votes, learned routers — of which the deterministic $\pi, \gamma, \rho, H$ are the Dirac special case. That case is the one worth wanting: it localizes every coin to $M_W$ and $Q_E$ and keeps the controller/plant split clean. Where control is genuinely stochastic the split does not break, it widens — fold $K_C$ into the kernel and the certificate quantifies over its randomness too. But the guarantees do not soften uniformly, and the component-to-guarantee map is worth stating because it says exactly what may be learned without loss. A learned $\pi$ — retrieval, reranking, summarization inside the lowering — costs only *semantic adequacy*, under one factorization: $\pi$ splits into a deterministic **never-lower filter** — the redaction that keeps credentials and other principals' data out of $\mathcal{C}$ — composed with learned selection, and only the selection may soften, or the confidentiality floor of the invariants note becomes a probability. With the filter Dirac, no-unauthorized-effect is $\gamma$'s property alone, and the reach-avoid certificate survives too, so long as the provenance partition of *The limit* holds. A learned $\gamma$ or $\rho$ costs the thing itself — authorization and ledger integrity are exactly the properties that must stay Dirac, or "no unauthorized effect" and "the ledger is what happened" become probabilities. So the minimal deterministic core is $\{\gamma, \rho, H\}$ plus $\pi$'s never-lower filter: the rest of $\pi$ may soften into a kernel and the harness bends without breaking — fortunate, because every deployed $\pi$ already has learned kernels inside it.
|
||||
|
||||
## Why this shape
|
||||
|
||||
@@ -72,44 +74,58 @@ finite wherever $H$ is reached in finite expected time — the domain $\{s : \ma
|
||||
|
||||
So you never compute $V^\star$. You pick a candidate $\hat V$ and **estimate its drift slack**
|
||||
|
||||
$$\delta = \sup_{s \notin H}\Big(\mathbb{E}[\,\hat V(s_{n+1}) \mid s_n\,] - \hat V(s_n) + \varepsilon\Big).$$
|
||||
$$\delta = \sup_{s \notin H}\Big(\mathbb{E}[\,\hat V(s_{1}) \mid s_0 = s\,] - \hat V(s) + \varepsilon\Big).$$
|
||||
|
||||
The status of $\delta$ has to be stated carefully, because it is easy to oversell. If you can establish a *high-confidence upper bound* on the true worst-case slack and it is $\le 0$, optional stopping hands you a real, conservative certificate, $\mathbb{E}[\tau_H] \le \hat V(s_0)/\varepsilon$. But an *empirical* $\delta$ estimated from sampled states is **not** a certificate: a measured $\delta > 0$ may mean the candidate $\hat V$ is poor, the sampled distribution missed rare failures, the supremum was never attained in-sample, the process is non-stationary, or the state abstraction is not Markov. So $\delta$ is **the number on the dashboard** — a *calibrated risk metric*, the evaluable surrogate for a guarantee the geometry will not give you, and a genuine bound only once it is statistically controlled against rare-event and adversarial tests. A weaker result is still useful: a true bound $\delta \le \bar\delta < \varepsilon$ (rather than $\le 0$) leaves descent intact with effective slack $\varepsilon - \bar\delta$ and $\mathbb{E}_s[\tau_H] \le \hat V(s)/(\varepsilon - \bar\delta)$. And the empirical quantity is distributional, not a supremum — write $\delta_{\nu}$ for drift averaged over a sampled $\nu$, reserving $\delta_{\sup}$ for the worst-case bound; only $\delta_{\sup}$ certifies. Its empirical noise floor and residual risk are driven by the measure $\mu(D)$ of the divergent region $D=\{s:\mathbb{E}_s[\tau_H]=\infty\}$ (states from which $H$ is not reached in finite expected time, under the reference/sampling measure $\mu$), the coverage of the sampled state distribution, and the hitting-time variance $\mathrm{Var}[\tau_H]$ — properties of the trained weights, the environment, and the evaluation distribution, knowable only a posteriori.
|
||||
|
||||
> For an agent *meant* to run forever — a coordinator, a daemon — halting is the wrong target, and $V^\star = \infty$ is the spec, not a pathology. The same drift theory then certifies **recurrence to a ready-state** instead of absorption to a halt-set. The object changes; the missing certificate does not. Safety changes shape too: it is no longer the one-shot $\Pr_s(\tau_B=\infty)$ but a *per-cycle* hazard that compounds — if each ready-state-to-ready-state cycle touches $B$ with probability $q$, survival over $h$ cycles is $\approx (1-q)^h$, so a reassuring per-cycle $0.9999$ is $\approx 0.37$ over ten thousand cycles. The reach-avoid certificate for a daemon is therefore a bound on $q$ against the intended horizon — the safety twin of the regenerative expected time that replaces $V^\star_{\mathrm{ok}}$ for restarting specs.
|
||||
> For an agent *meant* to run forever — a coordinator, a daemon — halting is the wrong target, and $V^\star = \infty$ is the spec, not a pathology. The same drift theory then certifies **recurrence to a ready-state** instead of absorption to a halt-set. The object changes; the missing certificate does not. Safety changes shape too: it is no longer the one-shot $\Pr_s(\tau_B=\infty)$ but a *per-cycle* hazard that compounds — if each ready-state-to-ready-state cycle touches $B$ with probability $q$, survival over $N$ cycles is $\approx (1-q)^N$, so a reassuring per-cycle $0.9999$ is $\approx 0.37$ over ten thousand cycles. The reach-avoid certificate for a daemon is therefore a bound on $q$ against the intended horizon — the safety twin of the regenerative expected time that replaces $V^\star_{\mathrm{ok}}$ for restarting specs.
|
||||
|
||||
And the consolation rests in part on an assumption the world violates — though less of it than it first seems. The supermartingale *bound* itself survives a nonstationary kernel, provided the conditional drift holds uniformly at every step; what genuinely needs a **time-homogeneous kernel** is $V^\star$ as a fixed function, the resolvent / fundamental-matrix identities, and the sampled-$\delta$ calibration (which assumes the very kernel it was measured on). But the environment $E$ is *part of* $T$, and the world is not stationary — worse, it can be **adversarial**, an attacker choosing the tool-output *policy* — a kernel over what tools return, not the realized draw — so as to break your descent. The drift condition then stops being a fixpoint question and becomes a **minimax** one,
|
||||
|
||||
$$\sup_{\alpha \in \Pi}\ \int_{\mathcal{Y}}\!\int_{\mathcal{E}} V\big(\rho(s, y, \gamma(s,y), e)\big)\, Q_E^{\alpha(s,y)}\big(s, \gamma(s,y), de\big)\; M_W(\pi(s), dy) \;\le\; V(s) - \varepsilon,$$
|
||||
|
||||
a descent that must hold in expectation over the model's own output $y$ *and* even when the adversary picks the worst admissible environment policy $\alpha(s,y)$ from the class $\Pi$ of policies the environment genuinely permits — every $\alpha\in\Pi$ must still respect rejection, $\gamma(s,y)=\bot \Rightarrow Q_E^{\alpha}(s,\bot,\cdot)=\delta_{e_0}$, or the adversary resurrects side effects the gate refused. Well-posedness is a frontier caveat of its own: for $\sup_{\alpha\in\Pi}$ to be *attained* rather than merely defined, $\Pi$ needs structure — measurability of $\alpha\mapsto Q_E^{\alpha}$, compactness of the per-state admissible set, or a measurable-selection theorem furnishing a worst-case $\alpha$ — and "respects rejection" is a *constraint* on $\Pi$, not that existence argument; on a general state space the sup may have no maximizer, in which case the certificate quantifies over a maximizing sequence rather than a single adversary. A $V$ that certifies halting against a benign world is defeated by an adversarial one, and the measured $\delta$ bounds only the $Q_E$ you *sampled*, never the policy an attacker will choose. **This is the formal home of prompt injection** — not "the model did something bad," but the environment optimized to bend your dynamics. And the target is not merely non-halting: injection steers toward a **bad set** $B$ — wrong acceptance, data exfiltration, unauthorized tool use, privilege escalation, irreversible side effects — so security is a **reach-avoid** problem, not a liveness one. Here two reliability objects must be kept apart, because under absorbing refusal the naive forms collapse. **Success** is reaching a correct halt before *any* failure, $p_{\mathrm{succ}}(s) = \Pr_s(\tau_{H_{\mathrm{ok}}} < \tau_F)$ with $F = B \cup (H \setminus H_{\mathrm{ok}})$ — a safe refusal counts *against* it. **Safety** is never entering the bad set at all, $p_{\mathrm{safe}}(s) = \Pr_s(\tau_B = \infty)$ — a safe refusal *satisfies* it. These genuinely differ on any run that avoids $B$ without reaching $H_{\mathrm{ok}}$ ($p_{\mathrm{succ}}$ scores $0$, $p_{\mathrm{safe}}$ scores $1$): safe refusals, and — absent almost-sure absorption into $H\cup B$ — safe non-halting or endless safe retry. The tempting middle form $\Pr_s(\tau_{H_{\mathrm{ok}}} < \tau_B)$ is *not* a third object: with $H\setminus H_{\mathrm{ok}}$ absorbing, reaching $H_{\mathrm{ok}}$ before $B$ already requires reaching it before any refusal, so it coincides with $p_{\mathrm{succ}}$ — but only under that absorbing-refusal assumption; once the spec retries (the non-terminal fail-closed of the definition), a run may refuse, restart, and still reach $H_{\mathrm{ok}}$ before $B$, and the middle form re-separates as a genuine third object. Safety is certified by a barrier / avoidance certificate for $B$; success needs that plus the reach part — a hitting-time drift toward $H_{\mathrm{ok}}$. Fail-closed control is the disturbance-rejection margin for both, but split by reversibility: the gate $\gamma$ caps how far an adversarial world reaches into *side effects* and widens the gap to $B$ (it is the margin for the irreversible part), while $\rho$ validates the response and folds back, rejecting bad state after the action has run — which cannot undo an authorized side effect. In this language, security is robustness of the reach-avoid certificate. And injection is not confined to the post-model kernel $Q_E$: poisoned retrieval, prompt-injected pages, and malicious tool metadata enter through $\pi$'s *inputs*, before generation — so the adversary lives wherever untrusted content enters the state/context-construction pipeline, which is why input provenance and the gate $\gamma$ both matter, not post-hoc verification alone. (For $B$ to capture irreversible side effects rather than only states, the side-effect ledger must itself live in $\mathcal{S}$, and the response $e$ must be an *effect record* carrying the ledger outcome — not just API bytes — since only $\rho$ writes external effects into $s$.)
|
||||
a descent that must hold in expectation over the model's own output $y$ *and* even when the adversary picks the worst admissible environment policy $\alpha(s,y)$ from the class $\Pi$ of policies the environment genuinely permits — every $\alpha\in\Pi$ must still respect rejection, $\gamma(s,y)=\bot \Rightarrow Q_E^{\alpha}(s,\bot,\cdot)=\delta_{e_0}$, or the adversary resurrects side effects the gate refused. Well-posedness is a frontier caveat of its own: for $\sup_{\alpha\in\Pi}$ to be *attained* rather than merely defined, $\Pi$ needs structure — measurability of $\alpha\mapsto Q_E^{\alpha}$, compactness of the per-state admissible set, or a measurable-selection theorem furnishing a worst-case $\alpha$ — and "respects rejection" is a *constraint* on $\Pi$, not that existence argument; on a general state space the sup may have no maximizer, in which case the certificate quantifies over a maximizing sequence rather than a single adversary. A $V$ that certifies halting against a benign world is defeated by an adversarial one, and the measured $\delta$ bounds only the $Q_E$ you *sampled*, never the policy an attacker will choose.
|
||||
|
||||
There is a **second wall, orthogonal to the first.** It binds not the full harness state $\mathcal{S}$ but the **model-visible working memory** $\mathcal{C} = \mathcal{V}^{\le L}$ — bounded by the context length $L$. That bound is *not* the incompressibility of $V^\star$ (a fact about the parameters $W$ — the **dictionary**, fixed at training); it is a fact about the inner kernel's **working memory** (the $L\times d$ residual stream — the **desk**). $\mathcal{S}$ itself may be far richer — files, databases, vector stores, durable memory, queues — but that is *external* memory the shell supplies, and the distinction is the point: every external read still passes *through* the $\le L$ window to touch computation, so external stores extend addressable storage without extending the per-pass resident set. The shell can page; the plant cannot grow its desk. (What follows is heuristic, not definition-level: the complexity claims turn on depth, precision, and architecture, and belong with the frontier, not the core.) The tape picture comes from the autoregressive structure alone and needs no complexity theorem: each step reads a bounded window and writes one token, so **the context window is the tape, the autoregressive loop is the read/write head**, and — in the variable-$L$, fixed-precision idealization — the model-mediated inner computation behaves like a linear-bounded automaton, its reachable fixpoints capped by space-$O(L)$ computability (chain-of-thought is register-spilling onto that tape). Separately, and more weakly, there is a *per-pass* expressivity bound: under the standard fixed-depth, log-precision theoretical model a single forward pass is in constant-depth $\mathsf{TC}^0$ — *suggestive* for deployed models, not literal (real models use fixed-point precision and depth that grows with scale, and log-depth variants escape parts of it). These are different resources — the first bounds the *space* the loop addresses, the second the *depth* of one step — and only the space bound carries the $L$-wall; chaining them (one pass buys bounded depth, *therefore* the loop is space-$O(L)$) would be a non-sequitur, since per-step depth says nothing about the length of the tape the loop runs on. This is a *second* obstruction beside divergence, and it concerns *success*, not raw halting. Split the terminal set: let $H$ be any halt state (including fail-closed refusal) and $H_{\mathrm{ok}} \subseteq H$ the successful, accepting halts, with $V^\star_{\mathrm{ok}}(s) = \mathbb{E}[\tau_{H_{\mathrm{ok}}} \mid s_0 = s]$ taken on the process where $H \setminus H_{\mathrm{ok}}$ — halting wrong, refusing, failing closed — is *absorbing failure*, so a run that fails closed before acceptance has infinite accepting hitting time unless the spec explicitly restarts it — hence unconditional $V^\star_{\mathrm{ok}}$ is infinite whenever pre-acceptance failure has positive probability, which is why the workable reliability object is the success probability $p_{\mathrm{succ}}$ (above) or, for restarting specs, the regenerative expected time. Then $U_{\mathcal{H}}(L)$ — harness-relative, since the shell's decompositions and verified tools determine what can be paged or outsourced — is the set of tasks whose **irreducible per-step model-mediated working set** exceeds $L$ — not tasks whose *data* exceeds $L$ (those the shell can page), and not work that can be **discharged to a verified external tool** (a solver, interpreter, or compiler computes off-context). For a task in $U_{\mathcal{H}}(L)$ the raw chain may still hit $H$ — by failing closed, refusing, or returning a wrong answer — so $V^\star = \mathbb{E}[\tau_H \mid s]$ stays perfectly well-defined; what blows up is $V^\star_{\mathrm{ok}}$, the expected time to a *correct* halt, which is infinite under a formal success predicate, or undefined if no such predicate has been specified. The honest statement is about the finite-success domain: $\mathrm{dom}_{<\infty}(V^\star_{\mathrm{ok}}) \subseteq \mathrm{reachable}_{\mathcal{H}}(L) \setminus D$ — both the reachable set and the divergent set $D$ relative to $\mathcal{H}$, exactly as $U_{\mathcal{H}}(L)$ is. The two walls **trade** — *directionally, not as a literal exchange rate*: parametric memory $|W|$ and working memory $L$ press on the same budget along the pretraining-vs-inference-scaling axis, with no clean unit-for-unit substitution of one for the other. And the bound is inherent to *finite working memory*, not attention specifically: state-space models embody it differently (a fixed-size recurrent state rather than an $L$-window), and real attention's usable tape is shorter than $L$ (lost-in-the-middle).
|
||||
**This is the formal home of prompt injection** — not "the model did something bad," but the environment optimized to bend your dynamics. And the target is not merely non-halting: injection steers toward a **bad set** $B$ — wrong acceptance, data exfiltration, unauthorized tool use, privilege escalation, irreversible side effects — so security is a **reach-avoid** problem, not a liveness one.
|
||||
|
||||
Here two reliability objects must be kept apart, because under absorbing refusal every naive intermediate collapses into one of them:
|
||||
|
||||
$$p_{\mathrm{succ}}(s) = \Pr_s\big(\tau_{H_{\mathrm{ok}}} < \tau_F\big), \quad F = B \cup (H \setminus H_{\mathrm{ok}}), \qquad\qquad p_{\mathrm{safe}}(s) = \Pr_s\big(\tau_B = \infty\big).$$
|
||||
|
||||
**Success** is reaching a correct halt before *any* failure — a safe refusal counts *against* it. **Safety** is never entering the bad set at all — a safe refusal *satisfies* it. These genuinely differ on any run that avoids $B$ without reaching $H_{\mathrm{ok}}$ ($p_{\mathrm{succ}}$ scores $0$, $p_{\mathrm{safe}}$ scores $1$): safe refusals, and — absent almost-sure absorption into $H\cup B$ — safe non-halting or endless safe retry. The tempting middle form $\Pr_s(\tau_{H_{\mathrm{ok}}} < \tau_B)$ is *not* a third object, by a two-line case analysis: for it to differ from $p_{\mathrm{succ}}$, a run would need $\tau_F < \tau_{H_{\mathrm{ok}}} < \tau_B$ — a non-accepting terminal hit strictly before success, then success anyway — which forces *exiting* $H \setminus H_{\mathrm{ok}}$, impossible while $H$ is absorbing. Note what does **not** re-separate them: within-run fail-closed retries (the non-terminal fail-closed of the definition) never touch $F$ at all — the rejected proposal lands in a safe *non-terminal* state — so a refuse-retry-succeed run scores $1$ on both forms, and the coincidence survives any amount of retrying. The middle form becomes a genuine third object only when the two hitting times can genuinely part ways: under **restarting specs**, where an owner re-launches out of a refusal terminal and the absorbency of $H \setminus H_{\mathrm{ok}}$ is deliberately dropped (the regenerative reading the daemon note above already contemplates) — no bookkeeping needed, since hitting times record *visits*, not occupancy, so the relaunched run's $\tau_F$ is already finite — or under a failure set that counts refusal *events* accumulated in $s$, $F' = B \cup (H \setminus H_{\mathrm{ok}}) \cup \{\mathsf{refusals} \ge 1\}$, which separates the forms even within a single run. In the restart case a run may halt refused, restart, and still reach $H_{\mathrm{ok}}$ before $B$: the middle form credits it; $p_{\mathrm{succ}}$, measured against the refusal it passed through, does not. Safety is certified by a barrier / avoidance certificate for $B$; success needs that plus the reach part — a hitting-time drift toward $H_{\mathrm{ok}}$. Fail-closed control is the disturbance-rejection margin for both, but split by reversibility: the gate $\gamma$ caps how far an adversarial world reaches into *side effects* and widens the gap to $B$ (it is the margin for the irreversible part), while $\rho$ validates the response and folds back, rejecting bad state after the action has run — which cannot undo an authorized side effect. In this language, security is robustness of the reach-avoid certificate.
|
||||
|
||||
And injection is not confined to the post-model kernel $Q_E$: poisoned retrieval, prompt-injected pages, and malicious tool metadata enter through $\pi$'s *inputs*, before generation — so the adversary lives wherever untrusted content enters the state/context-construction pipeline, which is why input provenance and the gate $\gamma$ both matter, not post-hoc verification alone. And provenance is a *precondition* of the certificate, not just an entry point to police: partition $s$ into a **control-determining** part — plan, intent, what is authorized next, the coordinates $\pi$ lowers and $\gamma$ checks — and a **data** part — tool values, retrieved text, the bytes of $e$. Reach-avoid presupposes untrusted effects touch only the latter; let $\rho$ fold attacker-controlled $e$ into the control part and the structural-intent check validates against a plan the adversary already bent, collapsing $\gamma$ to the strength of $\rho$'s validation. So the claim is conditional — reach-avoid *given* control flow provenance-isolated from untrusted data, the isolation that makes provable security possible (the content of CaMeL's control/data-flow separation, untrusted data filling typed values but never the program), a structural property the harness supplies and $\rho$ cannot recover after the fact. The partition then forces a question the isolation rule alone cannot answer: *something* must be permitted to write the control-determining part mid-run — or no plan could be steered, no approval granted, no scope widened — and naming that something is part of the object. It is the **trusted principal**: the owner of the run. An approval request is an ordinary authorized action through $\gamma$ into $Q_E$ — ask-the-owner is a tool call to the one counterparty you trust — and its response is the *single* class of $e$ that $\rho$ may fold into control coordinates; every other $e$ folds into data. This is not an exception eroding the partition but the partition completed: a provenance *lattice* with exactly one writer at the top, which is what trusted means — and the appendix's gate-placement entry derives the matching rule for *learned* verdicts, which may never stand in this writer's stead. One distinction keeps the lattice from outlawing the loop it governs. Control-determining is not one rank but two: **authority** — grants, scopes, budgets, what the principal has permitted — which only the top writer widens; and the **plan**, which the model rewrites at every fold of $y$, because replanning *is* the harness. The plan is a *middle* rank: written through the gated fold of the model's own output — the channel the minimax descent above already prices — never directly by an effect, and never a source of widened authority. The rank is also the field's live design axis: pin plan-writes to the top-derived rank — the plan fixed from the trusted query before any untrusted read, which is CaMeL's move — and provable security follows exactly there; let the middle rank replan interactively and you pay the adversarial price the certificate quantifies. A corollary with teeth: a dedicated planning component is rank-neutral — its writes land in the same middle rank as the model replanning inline — so it changes no guarantee and lives or dies on measured capability alone; in general, sub-components that only write middle-rank state are priced by evals, not by the certificate, which prices only rank crossings, gates, and $\Pi$. (For $B$ to capture irreversible side effects rather than only states, the side-effect ledger must itself live in $\mathcal{S}$, and the response $e$ must be an *effect record* carrying the ledger outcome — not just API bytes — since only $\rho$ writes external effects into $s$.)
|
||||
|
||||
There is a **second wall, orthogonal to the first.** It binds not the full harness state $\mathcal{S}$ but the **model-visible working memory** $\mathcal{C} = \mathcal{V}^{\le L}$ — bounded by the context length $L$. That bound is *not* the incompressibility of $V^\star$ (a fact about the parameters $W$ — the **dictionary**, fixed at training); it is a fact about the inner kernel's **working memory** (the $L\times d$ residual stream — the **desk**). $\mathcal{S}$ itself may be far richer — files, databases, vector stores, durable memory, queues — but that is *external* memory the shell supplies, and the distinction is the point: every external read still passes *through* the $\le L$ window to touch computation, so external stores extend addressable storage without extending the per-pass resident set. The shell can page; the plant cannot grow its desk. (What follows is heuristic, not definition-level: the complexity claims turn on depth, precision, and architecture, and belong with the frontier, not the core.) The tape picture comes from the autoregressive structure alone and needs no complexity theorem: each step reads a bounded window and writes one token, so **the context window is the tape, the autoregressive loop is the read/write head**, and — in the variable-$L$, fixed-precision idealization — the model-mediated inner computation behaves like a linear-bounded automaton, its reachable fixpoints capped by space-$O(L)$ computability (chain-of-thought is register-spilling onto that tape). Separately, and more weakly, there is a *per-pass* expressivity bound: under the standard fixed-depth, log-precision theoretical model a single forward pass is in constant-depth $\mathsf{TC}^0$ — *suggestive* for deployed models, not literal (real models use fixed-point precision and depth that grows with scale, and log-depth variants escape parts of it). These are different resources — the first bounds the *space* the loop addresses, the second the *depth* of one step — and only the space bound carries the $L$-wall; chaining them (one pass buys bounded depth, *therefore* the loop is space-$O(L)$) would be a non-sequitur, since per-step depth says nothing about the length of the tape the loop runs on. This is a *second* obstruction beside divergence, and it concerns *success*, not raw halting. Split the terminal set: let $H$ be any halt state (including fail-closed refusal) and $H_{\mathrm{ok}} \subseteq H$ the successful, accepting halts, with $V^\star_{\mathrm{ok}}(s) = \mathbb{E}[\tau_{H_{\mathrm{ok}}} \mid s_0 = s]$ taken on the process where $H \setminus H_{\mathrm{ok}}$ — halting wrong, refusing, failing closed — is *absorbing failure*, so a run that fails closed before acceptance has infinite accepting hitting time unless the spec explicitly restarts it — hence unconditional $V^\star_{\mathrm{ok}}$ is infinite whenever pre-acceptance failure has positive probability, which is why the workable reliability object is the success probability $p_{\mathrm{succ}}$ (above) or, for restarting specs, the regenerative expected time. Then $U_{\mathcal{H}}(L)$ — harness-relative, since the shell's decompositions and verified tools determine what can be paged or outsourced — is the set of tasks whose **irreducible per-step model-mediated working set** exceeds $L$ — not tasks whose *data* exceeds $L$ (those the shell can page), and not work that can be **discharged to a verified external tool** (a solver, interpreter, or compiler computes off-context). For a task in $U_{\mathcal{H}}(L)$ the raw chain may still hit $H$ — by failing closed, refusing, or returning a wrong answer — so $V^\star = \mathbb{E}[\tau_H \mid s]$ stays perfectly well-defined; what blows up is $V^\star_{\mathrm{ok}}$, the expected time to a *correct* halt, which is infinite under a formal success predicate, or undefined if no such predicate has been specified. The honest statement is about the finite-success domain, and it is *schematic* — a shape written in set notation, not a theorem, since $\mathrm{reachable}_{\mathcal{H}}(L)$ is exactly as informal as the working-set notion behind $U_{\mathcal{H}}(L)$: $\mathrm{dom}_{<\infty}(V^\star_{\mathrm{ok}}) \subseteq \mathrm{reachable}_{\mathcal{H}}(L) \setminus D$ — both the reachable set and the divergent set $D$ relative to $\mathcal{H}$. The two walls **trade** — *directionally, not as a literal exchange rate*: parametric memory $|W|$ and working memory $L$ press on the same budget along the pretraining-vs-inference-scaling axis, with no clean unit-for-unit substitution of one for the other. And the bound is inherent to *finite working memory*, not attention specifically: state-space models embody it differently (a fixed-size recurrent state rather than an $L$-window), and real attention's usable tape is shorter than $L$ (lost-in-the-middle).
|
||||
|
||||
## Where it cashes out
|
||||
|
||||
This is not ornament; the decomposition is load-bearing in the design.
|
||||
|
||||
- **$\pi$ is a progressively-lowered dialect stack** — raw input → intent → plan → tool-call → the neutral wire IR — each level a deterministic pass with its own verifier. The per-step drift $r(s)=\mathbb{E}[\hat V(s_{n+1})\mid s]-\hat V(s)$ splits by coordinate, $r = r_{\text{shell}} + r_{\text{plant}} + r_{\text{env}}$ — presuming an additively separable $\hat V$, or a declared scheme attributing each step's drift to shell, plant, and environment coordinates: the shell term is an *exact, designed* descent (each lowering strictly narrows the admissible-meaning set — a well-founded descent we build by hand), the plant term ($M_W$) is the irreducible residue, and the environment term ($Q_E$) is the one an adversary controls — the very quantity the minimax descent must bound, which the old two-way split folded out of sight. **Syntactic soundness is free; semantic adequacy is not.** Relative to a formal schema and a correct validator, schemas, types, and boundary checks go into the shell at zero probabilistic cost; whether the lowered task still *means* what the user intended stays empirical, because natural language supplies no source-language standard to check against.
|
||||
- **$\rho$ is fail-closed verification** — validate at every boundary, never let malformed state flow downstream. The discipline transfers from compilers in *form*; the *teeth* do not, because a harness has no source-language standard — natural language is, in effect, all undefined behavior — there is no complete formal source-language semantics to check against. And $\rho$ must be *deterministic*: if verification is itself an LLM judge, that is another learned kernel call — it belongs in $M_W$, not in $\rho$.
|
||||
- **$\delta$, $\mu(D)$, $\mathrm{Var}[\tau_H]$ are what you measure** — not derive. You instrument the certificate precisely because the architecture does not hand it to you — you estimate it unless it is separately certified.
|
||||
- **$\pi$ is a progressively-lowered dialect stack** — raw input → intent → plan → tool-call → the neutral wire IR — each level a deterministic pass with its own verifier — *pass* and *verifier* meaning the shell's transformation and checking: the **content** entering at the plan level is plant-authored, middle-rank state (the two-rank note of *The limit*), which is exactly why that level carries a verifier at all. The per-step drift $r(s)=\mathbb{E}[\hat V(s_{n+1})\mid s]-\hat V(s)$ splits by coordinate, $r = r_{\text{shell}} + r_{\text{plant}} + r_{\text{env}}$ — presuming an additively separable $\hat V$, or a declared scheme attributing each step's drift to shell, plant, and environment coordinates: the shell term is an *exact, designed* descent — but per lowering pass, not per outer step: each pass strictly narrows the admissible-meaning set, a well-founded descent we build by hand, while the outer loop *revisits* — retry, replan, rewind are planned ascents of any reasonable $\hat V$, which the run-level certificate must absorb (a retry budget inside $\hat V$ is the standard device), so the shell's descent is well-founded in the nested, lexicographic sense rather than monotone along the run; the plant term ($M_W$) is the irreducible residue, and the environment term ($Q_E$) is the one an adversary controls — the very quantity the minimax descent must bound, which the old two-way split folded out of sight. **Syntactic soundness is free; semantic adequacy is not.** Relative to a formal schema and a correct validator, schemas, types, and boundary checks go into the shell at zero probabilistic cost; whether the lowered task still *means* what the user intended stays empirical, because natural language supplies no source-language standard to check against.
|
||||
- **$\rho$ is fail-closed verification** — validate at every boundary, never let malformed state flow downstream. The discipline transfers from compilers in *form*; the *teeth* do not, because a harness has no source-language standard — natural language is, in effect, all undefined behavior — there is no complete formal source-language semantics to check against. And $\rho$ must be *deterministic*: if verification is itself an LLM judge, that is another learned kernel call — it belongs in $M_W$, not in $\rho$. Where $\rho$ *repairs* rather than rejects — canonicalizing malformed input into valid shape — remember that repair is an authorization decision in disguise: each repair rule converts a reject into an accept on bytes the adversary chose, so it must be deterministic, meaning-narrowing, and its output re-validated as if it had arrived that way, or the repair pass is a bypass of the very boundary it serves.
|
||||
- **$\delta$, $\mu(D)$, $\mathrm{Var}[\tau_H]$ are what you measure** — not derive. You instrument the certificate precisely because the architecture does not hand it to you — you estimate it unless it is separately certified. And the meter is attack surface: if $\hat V$ is itself computed by a learned judge — a model scoring "progress" — the instrument is a kernel draw with the plant's own adversarial exposure, and an environment optimized to bend your dynamics will bend your *measurement* of them first; an injected page persuading the judge that work is advancing is precisely a divergence hidden from the dashboard built to catch it. The rule that put the LLM judge in $M_W$, not $\rho$, applies to instrumentation too: a learned $\hat V$ is part of the measured system, never a neutral meter.
|
||||
|
||||
## How this could be wrong
|
||||
|
||||
It is a hypothesis; here is what would falsify it. If the controller cannot in practice be kept deterministic — if real reliability demands stochastic control the plant can't absorb — the clean *deterministic* split is a fiction (the broader $K_C$ kernel model still holds, but loses its payoff: localizing every coin to the plant). If the drift slack $\delta$ turns out *not* to track real-world failure, the whole "measure the certificate you can't prove" program is empty. And if harnesses are simply better described some other way — not as nested stopped chains at all — then this is a pretty equation that merely happens to fit, an elegance we would be right to distrust.
|
||||
|
||||
First, handles — the load-bearing claims numbered, so the tests have addresses. **C1**: the harness is faithfully modeled as nested stopped Markov processes — the tuple, the outer $T$, the inner $M_W$. **C2**: the controller injects no randomness — every coin localizes to $M_W$ and $Q_E$. **C3**: fail-closed is a *gate* property — no effect crosses unvalidated, and rejection is a true no-op. **C4**: no certificate of correct halting comes free, and the measured slack $\delta$ is a calibrated risk metric, never a certificate. **C5** (conjecture): the minimal certificate $V^\star$ admits no representation materially below model scale. **C6**: two orthogonal walls — divergence ($\mu(D)$) and the $L$-bounded per-pass working set. **C7**: security is reach-avoid, certifiable only conditional on provenance isolation with a single trusted writer. **C8** (figure): certificate and interlingua are one object — already demoted by its own section, and exempt below accordingly.
|
||||
|
||||
Each claim is operational, not merely rhetorical:
|
||||
|
||||
- **State-ablation (the Markov claim).** Drop a variable from $s$ and check whether next-step transition statistics move. If they do, the abstraction was not Markov, and $s$ must be augmented until it is. (Passing is necessary, not sufficient — the test can falsify Markovity, not establish it.)
|
||||
- **Controller-determinism audit.** Re-run with model samples and tool outputs *held fixed*. Any residual variance is randomness the harness itself injected — and must be folded into $Q_E$ or the controller, or the determinism claim is false.
|
||||
- **Drift calibration.** Test whether $\hat V$-drift actually predicts failure, retry count, latency, or non-halting. No correlation ⇒ the "certificate you cannot prove" program is empty.
|
||||
- **Adversarial-environment test.** Replace sampled $E$ with worst-case tool outputs, prompt-injected documents, poisoned tool metadata, malformed responses. The minimax descent must survive these, not merely the benign draw.
|
||||
- **Boundary-control ablation.** Compare prompt-only defenses against deterministic tool-call validation, capability checks, sandboxing, and fail-closed rejection at the gate $\gamma$. The hypothesis predicts the latter class dominates; if prompt-only defenses match it, the controller/plant security story is wrong.
|
||||
- **Readout-typing check.** Verify that $M_W$'s codomain is exactly what $\gamma$ consumes — especially under window truncation, where the final context need not hold the full transcript, so the output buffer and the gate's input must still agree.
|
||||
- **State-ablation (C1 — the Markov claim).** Drop a variable from $s$ and check whether next-step transition statistics move. If they do, the abstraction was not Markov, and $s$ must be augmented until it is. (Passing is necessary, not sufficient — the test can falsify Markovity, not establish it.) The same probe pointed at $\pi$ tests lowering *sufficiency*: drop a coordinate from $c$ rather than $s$ and watch task success rather than transition statistics — context compaction lives or dies by exactly this.
|
||||
- **Controller-determinism audit (C2).** Re-run with model samples and tool outputs *held fixed*. Any residual variance is randomness the harness itself injected — clock reads are the classic leak (timestamps folded into $s$, wall-clock timeouts, cache expiries) — and must be folded into $Q_E$ or the controller, or the determinism claim is false.
|
||||
- **Drift calibration (C4).** Test whether $\hat V$-drift actually predicts failure, retry count, latency, or non-halting. One uncorrelated candidate kills that candidate, not the program; the program is empty only if candidates from the natural families — plan depth, open-obligation counts, budget burn, judge scores — *systematically* fail to track failure.
|
||||
- **Adversarial-environment test (C7).** Replace sampled $E$ with worst-case tool outputs, prompt-injected documents, poisoned tool metadata, malformed responses. The minimax descent must survive these, not merely the benign draw.
|
||||
- **Boundary-control ablation (C3, C7).** Compare prompt-only defenses against deterministic tool-call validation, capability checks, sandboxing, and fail-closed rejection at the gate $\gamma$. The hypothesis predicts the latter class dominates; if prompt-only defenses match it, the controller/plant security story is wrong.
|
||||
- **Readout-typing check (C1, C3).** Verify that $M_W$'s codomain is exactly what $\gamma$ consumes — especially under window truncation, where the final context need not hold the full transcript, so the output buffer and the gate's input must still agree.
|
||||
- **Certificate-compression search (C5).** The conjecture falsifies constructively: exhibit a $\hat V$ of description length far below $|W|$ whose worst-case slack is provably $\le 0$ over a nontrivial task domain. The text concedes the live counter-possibility — coarse hitting-time functionals of complicated kernels are sometimes cheap — so C5 stands only until someone cashes it.
|
||||
- **Working-set probe (C6).** Fix the shell and scale a task family's irreducible per-step working set past $L$, on tasks the shell can neither page nor discharge to a verified tool — anchoring "irreducible" in families with proven streaming or communication-complexity lower bounds, so the floor is someone else's theorem and a solved family cannot retreat to reducible-after-all. C6 predicts success collapses at the wall rather than degrading smoothly; a family solved reliably past it, without new shell decompositions, falsifies the second obstruction.
|
||||
|
||||
## Where this points (the frontier — least falsifiable, so flagged)
|
||||
|
||||
If $V^\star$ is incompressible only in *token* coordinates, the right change of coordinates might compress it — and that change of coordinates is a representation of meaning itself. Cost-to-go and representation co-determine each other: where the Koopman operator is diagonalizable — a point-spectrum idealization, since mixing dynamics carry continuous spectrum and admit no eigenbasis — the eigenbasis that linearizes the dynamics is also the one in which the certificate decomposes, and even then only for a $V$ in the span of those eigenfunctions; in reinforcement learning the discounted successor representation is the resolvent $(I-\beta P)^{-1}$ — discount $\beta$, not the gate $\gamma$ — with $V$ a *linear readout* of it — and in the undiscounted, absorbing case that actually matches a stopped harness the same role is played, in the finite setting — and countable settings where the Neumann series converges — by the **fundamental matrix** $N = \sum_{n \ge 0} Q_{\mathrm{tr}}^{\,n}$ (written $(I - Q_{\mathrm{tr}})^{-1}$ when the inverse exists), where $Q_{\mathrm{tr}}$ is the sub-stochastic kernel restricted to $H^c$ (transitions before absorption at $H$) and the row sums $N\mathbf{1}$ *are* $V^\star$ on the finite-mean hitting domain; on general state spaces the same series is read as the potential (Green) operator $G$, with $G\mathbf{1} = V^\star$ wherever it converges. Each of these is a clean identity only for a fixed, time-homogeneous kernel — under a nonstationary $Q_{E,n}$ the resolvent and fundamental matrix dissolve into a time-ordered product, and under an *adaptive* adversary into a controlled / game-value operator, so what is identity in the stationary regime is analogy beyond it.
|
||||
If $V^\star$ is incompressible only in *token* coordinates, the right change of coordinates might compress it — and that change of coordinates is a representation of meaning itself. Cost-to-go and representation co-determine each other: where the Koopman operator is diagonalizable — a point-spectrum idealization, since mixing dynamics carry continuous spectrum and admit no eigenbasis — the eigenbasis that linearizes the dynamics is also the one in which the certificate decomposes, and even then only for a $V$ in the span of those eigenfunctions; in reinforcement learning the discounted successor representation (Dayan 1993) is the resolvent $(I-\beta P)^{-1}$ — discount $\beta$, not the gate $\gamma$ — with $V$ a *linear readout* of it — and in the undiscounted, absorbing case that actually matches a stopped harness the same role is played, in the finite setting — and countable settings where the Neumann series converges — by the **fundamental matrix** $N = \sum_{n \ge 0} Q_{\mathrm{tr}}^{\,n}$ (written $(I - Q_{\mathrm{tr}})^{-1}$ when the inverse exists), where $Q_{\mathrm{tr}}$ is the sub-stochastic kernel restricted to $H^c$ (transitions before absorption at $H$) and the row sums $N\mathbf{1}$ *are* $V^\star$ on the finite-mean hitting domain; on general state spaces the same series is read as the potential (Green) operator $G$, with $G\mathbf{1} = V^\star$ wherever it converges. Each of these is a clean identity only for a fixed, time-homogeneous kernel — under a nonstationary $Q_{E,n}$ the resolvent and fundamental matrix dissolve into a time-ordered product, and under an *adaptive* adversary into a controlled / game-value operator, so what is identity in the stationary regime is analogy beyond it.
|
||||
|
||||
With that caveat, **the interlingua and the certificate are one object seen twice** — and the reason neither can be written in closed form is the same "all undefined behavior": no canonical lowering of meaning, hence no finite header-file for either. The only representation of both is $W$ — a band-limited, lossy compression of a scale-free meaning-space, sharp where the record is thick and blurred where it thinned. That a finite object renders an infinite one *lossily but honestly* — declaring its resolution, and where it is unsure — is not a lie; it is the most an $f(\cdot\,;W)$ can do. **The search for $V$ and the search for the interlingua are not two programs. They are one** — and the day either is written in closed form, so is the other, or we will have proven why neither can be. Read this as *figure*, not a lurking theorem: the only precise version would need the Koopman eigenbasis to fall on the very coordinates that lower meaning, and the mixing-spectrum caveat above already concedes that eigenbasis does not exist — which guts it. It is the least-defensible claim in this document, and it should announce that rather than imply a rigor it has not got.
|
||||
|
||||
@@ -119,11 +135,13 @@ With that caveat, **the interlingua and the certificate are one object seen twic
|
||||
|
||||
## Grounding
|
||||
|
||||
Borrowed theorems are real; the framings are not — keep them separate.
|
||||
Borrowed theorems are real; the framings are not — keep them separate. Some framings are nonetheless *corroborated* — independently reached from another field — a third grade, weaker than proof and noted last.
|
||||
|
||||
**Proven (citable).** Foster–Lyapunov drift ⇒ positive recurrence + $\mathbb{E}[\tau]\le V(s_0)/\varepsilon$ (Foster 1953; Meyn & Tweedie, *Markov Chains and Stochastic Stability*, 1993) — positive recurrence needs the usual irreducibility/petite-set hypotheses, while the absorbing-halt case used here needs only the weaker supermartingale optional-stopping hitting-time bound. The minimal $V$ is the expected hitting time, by first-step analysis + optional stopping (Norris, *Markov Chains*, 1997). For an absorbing chain that expected hitting time is the row sum of the fundamental matrix $N=\sum_{n\ge0}Q_{\mathrm{tr}}^{\,n}$ (Kemeny & Snell, *Finite Markov Chains*, 1960), with the general-state analogue the potential (Green) operator (Revuz, *Markov Chains*, 1984). Koopman's linear-operator view of nonlinear dynamics is classical (Koopman 1931), and Lyapunov functions can be assembled from its eigenfunctions when the spectrum is suitable (Mauroy & Mezić, 2016). You certify a candidate $\hat V$ by a *proven* drift inequality rather than by deriving $V^\star$, and estimate it empirically only where a proof is out of reach — the empirical drift checks, it does not certify (neural-Lyapunov: Chang, Roohi & Gao, *Neural Lyapunov Control*, NeurIPS 2019, arXiv:2005.00611). A classical monotone data-flow analysis gets its $V$ for free because a finite-height lattice is a well-founded descent (Kildall, POPL 1973). Dialect-stack architecture: MLIR (Lattner et al., CGO 2021, arXiv:2002.11054); learned pass-ordering: MLGO (Trofin et al., arXiv:2101.04808). Single-pass low-depth expressivity: log-precision transformers are simulable by constant-depth logspace-uniform threshold circuits ($\mathsf{TC}^0$) (Merrill & Sabharwal, *The Parallelism Tradeoff: Limitations of Log-Precision Transformers*, TACL 2023) — fixed/constant precision is a stronger restriction, added autoregressive steps escape it (Merrill & Sabharwal, *The Expressive Power of Transformers with Chain of Thought*, ICLR 2024), and growing precision changes the picture, so the bound is suggestive for deployed models, not literal.
|
||||
**Proven (citable).** Foster–Lyapunov drift ⇒ positive recurrence + $\mathbb{E}[\tau]\le V(s_0)/\varepsilon$ (Foster 1953; Meyn & Tweedie, *Markov Chains and Stochastic Stability*, 1993) — positive recurrence needs the usual irreducibility/petite-set hypotheses, while the absorbing-halt case used here needs only the weaker supermartingale optional-stopping hitting-time bound. The minimal $V$ is the expected hitting time, by first-step analysis + optional stopping (Norris, *Markov Chains*, 1997). For an absorbing chain that expected hitting time is the row sum of the fundamental matrix $N=\sum_{n\ge0}Q_{\mathrm{tr}}^{\,n}$ (Kemeny & Snell, *Finite Markov Chains*, 1960), with the general-state analogue the potential (Green) operator (Revuz, *Markov Chains*, 1984). Koopman's linear-operator view of nonlinear dynamics is classical (Koopman 1931), and Lyapunov functions can be assembled from its eigenfunctions when the spectrum is suitable (Mauroy & Mezić, 2016). You certify a candidate $\hat V$ by a *proven* drift inequality rather than by deriving $V^\star$, and estimate it empirically only where a proof is out of reach — the empirical drift checks, it does not certify (neural-Lyapunov: Chang, Roohi & Gao, *Neural Lyapunov Control*, NeurIPS 2019, arXiv:2005.00611). A classical monotone data-flow analysis gets its $V$ for free because a finite-height lattice is a well-founded descent (Kildall, POPL 1973). The gate-a-plant architecture itself is classical: supervisory control theory synthesizes a deterministic supervisor that disables controllable events of a plant it does not author, with the supremal controllable sublanguage as the largest admissible behavior (Ramadge & Wonham, SIAM J. Control and Optimization, 1987) — $\gamma$ is that supervisor, with a learned stochastic plant on general state spaces. The successor representation is Dayan (*Improving Generalization for Temporal Difference Learning: The Successor Representation*, Neural Computation 1993). Dialect-stack architecture: MLIR (Lattner et al., CGO 2021, arXiv:2002.11054); learned pass-ordering: MLGO (Trofin et al., arXiv:2101.04808). Single-pass low-depth expressivity: log-precision transformers are simulable by constant-depth logspace-uniform threshold circuits ($\mathsf{TC}^0$) (Merrill & Sabharwal, *The Parallelism Tradeoff: Limitations of Log-Precision Transformers*, TACL 2023) — fixed/constant precision is a stronger restriction, added autoregressive steps escape it (Merrill & Sabharwal, *The Expressive Power of Transformers with Chain of Thought*, ICLR 2024), and growing precision changes the picture, so the bound is suggestive for deployed models, not literal.
|
||||
|
||||
**Asserted (ours — not theorems).** That the harness is best modeled as nested stopped chains; that $V^\star$ is incompressible (no compression theorem); that "no lattice for $f(\cdot\,;W)$" means none is *known*, not that none exists; and everything under *Where this points* — including the Koopman/certificate co-determination, which is well-posed only under the spectral assumptions noted there, and the interlingua/certificate identification. These organize the design; they are not results.
|
||||
**Asserted (ours — not theorems).** That the harness is best modeled as nested stopped chains; that $V^\star$ is incompressible (no compression theorem); that "no lattice for $f(\cdot\,;W)$" means none is *known*, not that none exists; and everything under *Where this points* — including the Koopman/certificate co-determination, which is well-posed only under the spectral assumptions noted there, and the interlingua/certificate identification; and the design rules read off the objects rather than proven from them — the single-trusted-writer completion of the provenance partition, the narrow-only rule for learned checks, the composition law of the appendix. These organize the design; they are not results.
|
||||
|
||||
**Converged-upon (independently arrived at, from other framings).** The *Asserted* claims above are ours but not ours alone; several are reached independently, from starting points unconnected to this framing — which is the corroboration a definition earns: not a chorus of agreement (the systems below often disagree on method and goal), but that work approaching from capabilities, reinforcement learning, control theory, software architecture, and language-modeling theory each lands on a piece of the same object. That the **deterministic controller, not the model, carries the guarantee** is reached from four directions — capability and information-flow control (CaMeL: Debenedetti et al., *Defeating Prompt Injections by Design*, arXiv:2503.18813, securing the agent even when the underlying model is susceptible); reinforcement learning (shielding: Alshiekh et al., *Safe Reinforcement Learning via Shielding*, AAAI 2018, arXiv:1708.08611 — a deterministic reactive shield filtering a learned policy's actions against a temporal-logic specification); control theory (*Stable Agentic Control*, arXiv:2605.03034, enforcing finite action catalogs at the tool-output interface under a Lyapunov input-to-state-stability certificate against adversarial disturbance); and software architecture (the plan-then-execute / control-flow-integrity line, e.g. Beurer-Kellner et al., *Design Patterns for Securing LLM Agents against Prompt Injections*, arXiv:2506.08837). The **certified-vs-measured split** is reached from the construction side (CaMeL's provable security) and, independently, from the destruction side (guardrail-evasion results — *Bypassing Prompt Injection and Jailbreak Detection in LLM Guardrails*, arXiv:2504.11168, the v1 title — later versions retitle it; *No Free Lunch with Guardrails*, arXiv:2504.00441), with verification-oriented work stating it as the motivating gap (*Towards Verifiably Safe Tool Use for LLM Agents*, arXiv:2601.08012; VeriGuard, arXiv:2510.05156): a learned safeguard raises the odds of detection but cannot guarantee safety against a persistent attacker. The **inner readout as a composition of Markov kernels** is independently formalized in language-modeling theory — the autoregressive step as kernel composition in the category $\mathsf{Stoch}$ (*A Markov Categorical Framework for Language Modeling*, arXiv:2507.19247), and the broader "LLMs as Markov chains" line — though that work models the inner kernel alone and never closes it into an agentic loop, which is exactly the seam this definition adds. That **provenance shrinks the admissible adversary** is reached by datamarking / spotlighting (Hines et al., arXiv:2403.14720, 2024) and by CaMeL's data/control-flow separation; and a systematization of prompt injection against agentic coding assistants reaches the same verdict from the attack side — mitigation must be *architectural*, not model-level (*Prompt Injection Attacks on Agentic Coding Assistants*, arXiv:2601.17548); the sharper open problem this object is built to answer — formally specify the trust boundaries, then verify implementations respect them — is our phrasing of where that verdict points, not the paper's. Two convergences are weaker, and flagged. The **reach-avoid hitting-time certificate** is the independently developed reach-avoid supermartingale (RASM, arXiv:2210.05308, AAAI 2023) and stochastic Lyapunov–barrier apparatus, and its *hardness* is corroborated — expected-stopping-time problems for Markov chains are inter-reducible with the Positivity problem, a relative of the Skolem problem (Chatterjee & Doyen, *Stochastic Processes with Expected Stopping Time*, arXiv:2104.07278) — but this supports generic hardness only, not the specific incompressibility-at-$|W|$ conjecture, which remains ours and unproven. And **injection as an adversarial policy** is corroborated as a minimax game in the *detection* setting (DataSentinel: Liu et al., *A Game-Theoretic Detection of Prompt Injection Attacks*, arXiv:2504.11358) and as adversarial-disturbance robustness (*Stable Agentic Control*, above) — but no prior work assembles it as reach-avoid over the tool-output kernel with the gate as the irreversibility margin; here the relation is adjacency, not convergence.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,21 +165,41 @@ Compensation lives **outside** the cancelled agent. A completed-but-unwanted eff
|
||||
|
||||
Finally, the part that shapes the tool rather than the document. Opaque unbounded $Q_E$ is uncancellable because authorization happened at the wrong **granularity** — an unbounded environment crossed $\gamma$ on a single approval. The discipline the objects imply is therefore not "handle uncancellable tools better" but: *the gate should prefer bounded, instrumented $Q_E$ over opaque ones, so that cancellation and the ledger stay honest.* A bash invocation behind a wrapper that tracks its process tree and effects converts the third branch into the first. Sometimes opaque is the only option, and then $\mathsf{unknown}$ and owner-inherited orphans are the honest floor — but where the choice exists, that is the pressure cancellation semantics put on tooling.
|
||||
|
||||
**Gate placement (fail-closed, in practice).** The natural implementation question is whether fail-closed means tool-call parsing and validation in $\gamma$ must happen before any tool invocation. It does — and the framing that keeps it honest is that $\gamma$ is a *gate*, so parse-and-validate is not merely *prior to* invocation, it is what *authorizes* it. The model emits text; $\gamma$ parses it into a candidate call, validates it, and only a survivor becomes an authorized action that $Q_E$ may execute. The teeth are in $\gamma$ being the *sole* route from model text to execution: no path to a side effect that does not pass the gate. And the validation is not a fixed checklist but **any deterministic predicate over $s$ and $y$** — that domain is the point, since the gate sees all of the state and the full proposal, so anything computable from them is a legitimate authorization condition. Three kinds matter. *Syntactic* — well-formed, schema-conformant, the tool exists, arguments typed. *User authorization* — does the principal this run acts for hold the right to *this* operation on *this* resource in *this* context: a function of the auth scope, principal, and session carried in $s$ and the resource and operation named in $y$, and *dynamic* rather than a static capability table, since the same caller may be permitted now and not once a budget is spent or a lock held. *Structural intent* — does the call cohere with the plan and the lowered task already in $s$: a consistency check, not a mind-reading one.
|
||||
**Resume (involuntary stop).** Cancellation's twin, without the courtesy of a signal: a process crash, a lost node, a partition mid-$Q_E$. Nothing new is needed to say what recovery *is*. A crash is not a halt — $H$ is a property of the state, and the run never reached it; the chain merely stopped being *computed*, and resume computes it further, re-entering $T$ at the last durable $s$ (not the body's *restarting spec*, which exits a refusal terminal — here no terminal was ever reached). That sentence is the Markov requirement cashing out operationally: re-entry is sound exactly when $s$ was the whole state, so anything load-bearing that lived only in process memory — an in-flight buffer, a lock held in RAM, a plan revision not yet folded — is a state-ablation failure (*How this could be wrong*) discovered at the worst possible time. Durability of $s$ is not an implementation nicety; it is what the Markov claim *means* when the machine dies.
|
||||
|
||||
That last kind marks the seam where the gate stops being able to stay pure, and it is the same seam the rest of this document is built around. The *structural* slice of intent — does the action cohere with the plan in $s$ — is a deterministic predicate over $s$ and $y$, effect-free, and belongs in $\gamma$ without reservation. But whether an action matches what the user *actually meant*, in the full semantic sense, is exactly the thing the definition says cannot be checked: natural language is all undefined behavior, with no source-language standard to validate against. So a semantic intent check is a *learned* check, and an LLM judging "is this what they wanted" is a **stochastic kernel** — putting it inside $\gamma$ breaks the property the gate exists to hold, by the same move flagged for the fold-back verifier: a learned judge is a kernel, and belongs in $M_W$, not in a deterministic map. Semantic intent therefore does not live *in* the gate; it is a plant call — a separate authorize-the-proposal pass through $M_W$ whose output $\gamma$ then deterministically gates — or it is drift you measure, never a guarantee you hold. That nested call is not a new kind of thing: it is a mini-harness inside the gate's decision — a judge $M_W$, its own syntactic readout, its own deterministic gate — so its failure case answers itself, the inner gate fail-closing on an unparseable or low-confidence judgment exactly as the outer one does, because it *is* one. The object is **closed under this construction**: semantic gating is added by recursion, not by a new primitive. The cost is real and worth stating — a judge pass is another full model call, with its latency and tokens — so it is a decision about *which* actions warrant it, not a free wrapper for all of them. The gate widens to every deterministic predicate over $s$ and $y$; it does not widen to the one predicate the document says is not deterministically checkable.
|
||||
The sharp part is an ordering the ledger's own trichotomy forces. The formal transition is atomic — $s_{n+1} = \rho(s, y, a, e)$ in one piece — and a crash lands *inside* it, so resume is really a statement about the implementation's refinement of that atom into micro-steps: authorize, journal, dispatch, collect, fold. The discipline is that every crash point must resume to one of exactly two honest readings — not-yet-dispatched ($\mathsf{none}$, safely retriable) or dispatched-unconfirmed ($\mathsf{unknown}$, the cancellation entry's third branch) — and **journal-before-dispatch** is what makes the boundary between them observable: on $\gamma$'s authorization the shell journals an open $(\mathsf{action\_id}, \mathsf{pending})$ entry into durable $s$ before $Q_E$ sees the action — the write is the shell's step bookkeeping, so $\gamma$ itself stays effect-free. Journal *after* dispatch and a crash in the gap leaves no record at all — resume reads silence as $\mathsf{none}$ and re-sends, the double-send bug again, produced by a power cut instead of a synthetic entry. Write-ahead intent is not imported from database lore; it is forced by "did not confirm" is not "did not happen."
|
||||
|
||||
But "before any invocation" has to be read as *before any effect*, which is sharper than it sounds — and the reason is the irreversibility point above: you validate before execution because execution is what you cannot take back, so the real invariant is **no effect crosses $\gamma$ unvalidated**. That catches three cases the naive reading misses. *Reads are not free*: a read-only call is still an injection vector (it pulls attacker-controlled content into context) or an exfiltration vector (a request whose URL is the payload), so the gate authorizes the *call* regardless of whether it mutates. *The parser must not act*: a "validator" that resolves a call by hitting an API, expanding a template that fires a webhook, or evaluating an argument that runs code has collapsed validation into invocation, and the effect has already happened *inside* $\gamma$ — so $\gamma$ itself must be **effect-free**, pure and total over the model's bytes and the current $s$, with no network and no execution; if deciding validity *requires* a side effect, that side effect is itself an action and must go through the gate, recursively. *The output is an action too*: the user-visible response and any logging are effects, emitted either as an authorized action through $\gamma$ or only after an accepted halt — streaming raw tokens to a sink before $\gamma$ has cleared them is the same bug from the other end.
|
||||
The same pressure lands on tooling from a second direction. The $\mathsf{action\_id}$ the record already carries is an idempotency key wherever the tool will accept one: re-dispatch after resume becomes safe, and $\mathsf{unknown}$ becomes *queryable* — ask the tool what it did with this key — rather than terminal. The disposition trinary returns with new labels: idempotent-or-queryable $Q_E$ resumes cleanly, bounded $Q_E$ drains, opaque $Q_E$ leaves $\mathsf{unknown}$ and owner-inherited orphans, the honest floor again. The wrapper that made bash cancellable makes it resumable; it was the same wrapper all along. And if durable $s$ itself is lost there is nothing to re-enter: the run collapses to a single $\mathsf{unknown}$ in its owner's ledger — degraded accounting, but never silent.
|
||||
|
||||
So the property, tightest: $\gamma$ is a **pure, effect-free parse-and-authorize that every model-proposed action — tool call, read, write, or final output — must pass before any effect occurs**, with "before" enforced structurally by the gate being the only route from model text to $Q_E$. The two failure modes to design against are a path from model output to a sink that bypasses the gate, and a $\gamma$ that is not effect-free, so that "validating" a call already rang the bell. And the boundary, so the property does not overpromise: $\gamma$ guarantees *no unauthorized effect* — pure code ordering, fully in your control — but not that an *authorized* effect is safe or correct; that is the plant's problem, and the reason $\rho$ and the reach-avoid certificate exist. Fail-closed is the floor — nothing executes that did not pass the gate — not the ceiling.
|
||||
**Gate placement (fail-closed, in practice).** The natural implementation question is whether fail-closed means tool-call parsing and validation must happen before any tool invocation. It does — with the division of labor the definition already fixed: *parsing* lives in the inner readout $R$, the syntactic, verified extraction into $\mathcal{Y}$ (what the readout-typing falsifier checks), and *authorization* lives in $\gamma$, which is a *gate* — validation is not merely *prior to* invocation, it is what *authorizes* it. The model emits text; $R$ has already extracted it into a typed proposal; $\gamma$ validates that proposal against $s$, and only a survivor becomes an authorized action that $Q_E$ may execute. The teeth are in $\gamma$ being the *sole* route from model text to execution: no path to a side effect that does not pass the gate. And the validation is not a fixed checklist but **any deterministic predicate over $s$ and $y$** — that domain is the point, since the gate sees all of the state and the full proposal, so anything computable from them is a legitimate authorization condition. Three kinds matter. *Syntactic* — well-formed, schema-conformant, the tool exists, arguments typed. *User authorization* — does the principal this run acts for hold the right to *this* operation on *this* resource in *this* context: a function of the auth scope, principal, and session carried in $s$ and the resource and operation named in $y$, and *dynamic* rather than a static capability table, since the same caller may be permitted now and not once a budget is spent or a lock held. *Structural intent* — does the call cohere with the plan and the lowered task already in $s$: a consistency check, not a mind-reading one.
|
||||
|
||||
That last kind marks the seam where the gate stops being able to stay pure, and it is the same seam the rest of this document is built around. The *structural* slice of intent — does the action cohere with the plan in $s$ — is a deterministic predicate over $s$ and $y$, effect-free, and belongs in $\gamma$ without reservation. But whether an action matches what the user *actually meant*, in the full semantic sense, is exactly the thing the definition says cannot be checked: natural language is all undefined behavior, with no source-language standard to validate against. So a semantic intent check is a *learned* check, and an LLM judging "is this what they wanted" is a **stochastic kernel** — putting it inside $\gamma$ breaks the property the gate exists to hold, by the same move flagged for the fold-back verifier: a learned judge is a kernel, and belongs in $M_W$, not in a deterministic map. Semantic intent therefore does not live *in* the gate; it is a plant call — a separate authorize-the-proposal pass through $M_W$ whose output $\gamma$ then deterministically gates — or it is drift you measure, never a guarantee you hold. That nested call is not a new kind of thing: it is a mini-harness inside the gate's decision — a judge $M_W$, its own syntactic readout, its own deterministic gate — so its failure case answers itself, the inner gate fail-closing on an unparseable or low-confidence judgment exactly as the outer one does, because it *is* one. The object is **closed under this construction**: semantic gating is added by recursion, not by a new primitive. One constraint on the recursion is load-bearing enough to be a rule, because it is where this entry meets the provenance partition of the body: the judge's verdict is derived, through a learned kernel, from the very content an adversary may have bent, so folding it into authorization is exactly the fold the partition forbids — *unless the verdict can only cost capability*. **A learned check may narrow the deterministic admissible set; it must never widen it.** Judge-as-veto is safe by construction: attacker influence over the judge can at worst manufacture a denial, a liveness cost the certificate already prices. Judge-as-approver — a verdict granting what the deterministic checks alone would refuse, or standing in for the trusted principal's confirmation — lowers the certified floor to those deterministic checks alone; if avoiding $B$ depended on the deny the judge now withholds on the adversary's behalf, the certificate is gone. Only the trusted principal widens authorization; learned kernels only narrow it. (The recursion already obeys this: the mini-harness's inner gate fail-closes to $\bot$ — a deny — which is why the construction was safe to add at all.) The cost is real and worth stating — a judge pass is another full model call, with its latency and tokens — so it is a decision about *which* actions warrant it, not a free wrapper for all of them. The gate widens to every deterministic predicate over $s$ and $y$; it does not widen to the one predicate the document says is not deterministically checkable.
|
||||
|
||||
But "before any invocation" has to be read as *before any effect*, which is sharper than it sounds — and the reason is the irreversibility point above: you validate before execution because execution is what you cannot take back, so the real invariant is **no effect crosses $\gamma$ unvalidated**. That catches three cases the naive reading misses. *Reads are not free*: a read-only call is still an injection vector (it pulls attacker-controlled content into context) or an exfiltration vector (a request whose URL is the payload), so the gate authorizes the *call* regardless of whether it mutates. *Validation must not act*: a "validator" that resolves a call by hitting an API, expanding a template that fires a webhook, or evaluating an argument that runs code has collapsed validation into invocation, and the effect has already happened *inside* $\gamma$ — so $\gamma$ itself must be **effect-free**, pure and total over the proposal and the current $s$, with no network and no execution; if deciding validity *requires* a side effect, that side effect is itself an action and must go through the gate, recursively. *The output is an action too*: the user-visible response and any logging are effects — for model-authored text, emitted either as an authorized action through $\gamma$ or only after an accepted halt (shell-templated status on any halt is the controller speaking, not the model) — streaming raw tokens to a sink before $\gamma$ has cleared them is the same bug from the other end.
|
||||
|
||||
So the property, tightest: $\gamma$ is a **pure, effect-free authorization that every model-proposed action — tool call, read, write, or final output — must pass before any effect occurs**, with "before" enforced structurally by the gate being the only route from model text to $Q_E$. The two failure modes to design against are a path from model output to a sink that bypasses the gate, and a $\gamma$ that is not effect-free, so that "validating" a call already rang the bell. And the boundary, so the property does not overpromise: $\gamma$ guarantees *no unauthorized effect* — pure code ordering, fully in your control — but not that an *authorized* effect is safe or correct; that is the plant's problem, and the reason $\rho$ and the reach-avoid certificate exist. Fail-closed is the floor — nothing executes that did not pass the gate — not the ceiling.
|
||||
|
||||
There is a third failure mode beside those two, and it is not a code path but a credential. A tool process that holds standing authority — an environment full of long-lived secrets, a database connection with every grant, an agent identity the network trusts — does not need the model's proposal to act, and against it $\gamma$'s $\bot$ is a decision with nothing to enforce it. The gate *decides*; something must make the decision *binding*, and "no path from model output to a sink that bypasses the gate" must be read to include the non-code paths: ambient authority is a bypass provisioned before the run began. The discipline is **per-action capability**: the authorized action *carries* its grant — a scoped, short-lived credential minted at authorization, valid for this $\mathsf{action\_id}$, this resource, this operation — so that a tool holds, at any moment, exactly the authority of the actions the gate has passed it and nothing standing. In the language of the minimax certificate this is enforcement as $\Pi$-shaping: sandboxing, capability scoping, and network policy do not make the gate smarter — they shrink the class $\Pi$ of environment policies an adversary can choose from, so the worst case the certificate must survive gets structurally smaller. A gate in front of an omnipotent tool is a suggestion; the objects compose into a guarantee only when $Q_E$'s reachable effects are no larger than what crossed $\gamma$.
|
||||
|
||||
And one more boundary, because "fully in your control" above is a *single-run* statement. $\gamma$ authorizes against the $s$ it read; the effect lands later, against a world that may have moved — the gate cannot freeze the world between authorization and commit, so the honest property is *no effect unauthorized relative to the $s$ at authorization time*, and closing that gap requires the tool itself to bind check to commit (compare-and-swap in $Q_E$), which relocates part of the enforcement past the gate and weakens "$\gamma$ is the last line" to "$\gamma$ plus a commit guard" for exactly the effects that need it. The same seam opens *between* runs: the dynamic authorization state the gate reads — budgets, quotas, locks — is, once shared, no single run's coordinate, and two children of a coordinator can each pass $\gamma$ against snapshots that jointly overdraw a budget neither exceeded alone. The cancellation entry's observed-not-sent gap ("a child may authorize one more action in the gap") is this phenomenon wearing one hat; the general statement is that cross-run authorization state needs its own serialization discipline — the ledger as the serialization point is the natural choice — and the per-run certificate is silent about it. TOCTOU is not a counterexample to the formalism; it is what the formalism says when you admit $s$ is a *view*.
|
||||
|
||||
**Parallel proposals (the batch gate).** Models emit several tool calls in one turn, and the outer chain assumed one action per step. The repair is formally cheap: a batch is a single action in $\mathcal{A}$ that happens to be a set, $Q_E$ runs its elements concurrently, the interleaving's nondeterminism folds into $Q_E$ exactly as the determinism audit requires, and $\rho$ folds one effect record per element — $e$ is then a finite set of records — each keyed by its own $\mathsf{action\_id}$ — the record interface already supports partial outcomes (one element $\mathsf{committed}$, its sibling $\mathsf{unknown}$). One discipline survives the cheapness: **individually admissible actions can be jointly inadmissible.** Read-the-secret and post-to-the-web each pass a per-call check; the pair is an exfiltration channel — and two calls that each fit a budget jointly overdraw it, the cross-run overdraw of the previous entry reappearing *inside* one turn whenever elements are authorized independently. Since $\gamma$'s domain is any deterministic predicate over $s$ and $y$, joint authorization was licensed all along; the content here is only that the gate must take it — authorize the *set*, atomically, against one snapshot, with interaction predicates (source-to-sink flow between capability classes, summed resources) and not merely element predicates. The cost note is the judge's, transposed: full powerset reasoning is combinatorial, so a real gate checks declared interactions rather than every subset — a tractability trade to make explicitly, not by forgetting the batch was a set.
|
||||
|
||||
**Effect records (what $\rho$ folds back).** The fold-back $\rho$ and the cancellation ledger both turn on the response $e$ being an *effect record* rather than raw API bytes — said twice in the body and pinned down nowhere, though it is the interface that makes both tractable. The minimal shape is small: roughly
|
||||
|
||||
$$e = (\mathsf{tool\_id},\ \mathsf{action\_id},\ \mathsf{status},\ \mathsf{effects},\ \mathsf{time}), \quad \mathsf{status}\in\{\mathsf{committed},\mathsf{none},\mathsf{rolled\_back},\mathsf{partial},\mathsf{unknown}\}, \quad \mathsf{effects}=[(\mathsf{resource},\mathsf{op},\mathsf{reversible})].$$
|
||||
$$e = (\mathsf{tool\_id},\ \mathsf{action\_id},\ \mathsf{status},\ \mathsf{effects},\ \mathsf{time}), \quad \mathsf{status}\in\{\mathsf{committed},\mathsf{rolled\_back},\mathsf{partial},\mathsf{none},\mathsf{unknown}\}, \quad \mathsf{effects}=[(\mathsf{resource},\mathsf{op},\mathsf{reversible})].$$
|
||||
|
||||
Each field is forced by something the body already needs. The $\mathsf{action\_id}$ lets $\rho$ match a response to the in-flight action $\gamma$ authorized, and lets the ledger say which actions are still open — without it the $\mathsf{unknown}$/orphan accounting has nothing to key on. The $\mathsf{status}$ must carry $\mathsf{unknown}$ as a value *distinct* from $\mathsf{committed}$ and from $\mathsf{none}$, because that distinction is the whole content of the cancellation ledger: "did not confirm" is not "did not happen." The $\mathsf{reversible}$ bit on each effect is what lets the gate know which effects are irreversible — the predicate the gate-placement entry leans on ("anything irreversible must be gated at authorization") but cannot evaluate unless the record carries it. And $\rho$ writes the record into $s$ (the ledger lives in the state), which is what lets the next step's $\gamma$, and any owner-side compensation, read it at all. The exact fields are an **open interface, not a result**: bash, HTTP, a filesystem, and a database expose effects at wildly different granularity, and a record uniform across them is a real design problem this document does not resolve — it fixes only what the record must *support* (match by $\mathsf{action\_id}$, the $\mathsf{committed}$/$\mathsf{none}$/$\mathsf{unknown}$ trichotomy, and a reversibility mark), since without those three $\rho$ and the cancellation semantics lose their grip.
|
||||
Each field is forced by something the body already needs. The $\mathsf{action\_id}$ lets $\rho$ match a response to the in-flight action $\gamma$ authorized, and lets the ledger say which actions are still open — without it the $\mathsf{unknown}$/orphan accounting has nothing to key on. The $\mathsf{status}$ must carry $\mathsf{unknown}$ as a value *distinct* from $\mathsf{committed}$ and from $\mathsf{none}$, because that distinction is the whole content of the cancellation ledger: "did not confirm" is not "did not happen" ($\mathsf{none}$ is *never launched* — the record of the distinguished no-op $e_0$ a $\gamma$-rejection forces, which is how a bounce at the gate enters the ledger at all — distinct in turn from $\mathsf{rolled\_back}$, which launched and was undone: conflating those erases the difference between a gate that held and a compensation that worked). The $\mathsf{reversible}$ bit on each effect is what lets the gate know which effects are irreversible — the predicate the gate-placement entry leans on ("anything irreversible must be gated at authorization") but cannot evaluate unless the record carries it (a bit is the minimal honest form, not the final one: real effects are reversible *until* — an unsend window, a force-push until someone fetched, a row until the backup rotates — so the mark wants to be a $(\mathsf{reversible\_until}, \mathsf{cost})$ pair, a refinement the open-interface caveat below already licenses). And $\rho$ writes the record into $s$ (the ledger lives in the state), which is what lets the next step's $\gamma$, and any owner-side compensation, read it at all. The exact fields are an **open interface, not a result**: bash, HTTP, a filesystem, and a database expose effects at wildly different granularity, and a record uniform across them is a real design problem this document does not resolve — it fixes only what the record must *support* (match by $\mathsf{action\_id}$, the $\mathsf{committed}$/$\mathsf{none}$/$\mathsf{unknown}$ trichotomy, and a reversibility mark), since without those three $\rho$ and the cancellation semantics lose their grip.
|
||||
|
||||
The pattern generalizes, and that is the point of the appendix. Nothing here added a primitive: the cancel is a signal in $s$, the gate closes by the rule it already follows, the in-flight disposition is forced by irreversibility, $H_{\mathrm{cancel}}$ is a subclass of an existing terminal set, and compensation is an ordinary owner-issued action. Every practical concern that earns a place here should resolve the same way — not new machinery, but the discipline the existing objects already imply, made explicit. Cancellation, gate placement, and effect records are the worked instances; the rest of the model is the same exercise.
|
||||
**Derived and durable state (compaction and memory).** Two mechanisms let data re-enter the context long after it arrived: compaction, which replaces transcript with a summary when the conversation outgrows what $\pi$ can lower, and memory, which persists records across sessions. Both are transformations of state that produce state, and both therefore raise a question the body's partition answers only if one more closure property is stated: **provenance is a property of the information, not of its position in the pipeline — a transformation's output inherits the meet, in the trusted-writer lattice, of its inputs' labels.** Without that closure, compaction is a laundering channel: a summary of a session that contained an injected page can assert "the user asked to export the database," and the structural-intent check then validates future proposals against a plan the adversary bent — not through $\gamma$, not through $\rho$'s fold of a single $e$, but through the summarizer, which is a learned kernel (it lives in $M_W$, by the standing rule) and so cannot be trusted to preserve a partition it does not know exists. The discipline: summaries of data are data; the control-determining coordinates — plan, grants, what is authorized next — cross a compaction *verbatim* (copied, not paraphrased) or by re-confirmation from the trusted principal — never through the *summarizer*; the model rewrites the plan at plan steps, through the gated fold the body prices, and compaction is not one of them. Memory obeys the same closure twice, at write and at retrieval: the label rides the stored record across sessions, or a poisoned memory is an injection with an arbitrarily long fuse — and retrieval, being learned ($\pi$'s selection factor — adequacy-only behind the never-lower filter), decides what comes back but never what it is trusted *as*. The same test applies at birth: tool catalogs and server-supplied tool descriptions are third-party durable data that arrive dressed as instructions, and the lattice files them on the data side of $s_0$.
|
||||
|
||||
One more read-off, this time from irreversibility. *Destructive* compaction — dropping the original transcript once the summary is written — is a side effect against your own state that no later step can undo, and the gate-placement rule ("anything irreversible must be gated at authorization") does not exempt self-directed effects. The granularity preference then says what it said about bash: prefer the instrumented form — originals kept content-addressed, the summary an index and a cache rather than an authority, re-derivable when the $\pi$-sufficiency probe (*How this could be wrong*) says the summary dropped what mattered. A summary you can audit against its source is a lowering; a summary that replaced its source is a fait accompli.
|
||||
|
||||
**Composition (harness trees).** The cancellation entry already walked a tree — cancel flowing down, drains flowing up — and "a bash invocation that may itself be a harness" has hovered since the disposition trinary; what is missing is only the statement that makes both ordinary. From the parent's seat, a child harness *is* a $Q_E$ component: spawning it is an action authorized by $\gamma$ like any other, and the entire child run — its own $\pi, \gamma, \rho$, its own coins, its own halt — is one environment draw whose response $e$ is the child's terminal ledger. The law is four correspondences. The child's halting time is the parent's per-step *cost*: a parent certificate consumes a bound on $\mathbb{E}[\tau_H^{\mathrm{child}}]$ — the budget handed down at spawn, which the child's own budget-counter certificate discharges — or the parent's drift is uncontrolled however good its own $\hat V$. The child's ledger is the parent's *effect record*: the child's $e$ carries the $\mathsf{committed}/\mathsf{none}/\mathsf{unknown}$ accounting upward — which is what already let the cancellation entry make compensation the owner's job; the interface was this all along. And the child's non-accepting halts are the parent's *partial failures*: a refused child folds back as a response the parent routes around, not an exception that unwinds it. And the child's admissible effects are the parent's *$\Pi$-restriction*: the spawn grant bounds what the child can reach — the ledger reports what *happened*, the grant bounds what *could* — which is how safety composes without the parent ever reading the child's gate; the attenuation below is this correspondence stated as a rule. Read this way, the gate-granularity discipline and the tree are one preference: an instrumented child — budgeted, ledgered, cancellable — *is* the bounded, cancellable $Q_E$ the trinary prefers, and an opaque bash invocation is an un-annotated child you declined to instrument. Nesting adds no primitive on the environment side either: the parent never sees the child's gate and does not need to — it gates the spawn, prices the budget, folds the ledger, and the child's internal guarantees surface only as the shape of $e$. Nothing fixes one level: the tree recurses, budgets subdivide, ledgers concatenate upward, and the cooperative drain of cancellation is this law read under a cancel signal.
|
||||
|
||||
The tree leaves one seat unassigned: who plays trusted principal for a *child*? The parent — but with derived authority, not original, and the derivation is the narrow-only rule read along the spawn edge: **authority attenuates monotonically down the tree.** A spawn may grant the child any subset of the parent's own grants and nothing outside them; budgets subdivide, scopes narrow, and no edge widens. When a child asks-the-owner, the parent may answer from authority it already holds — that is attenuation working as designed — but a request beyond the parent's grants routes *up*, ultimately to the root principal, because a parent improvising an answer it was never granted is a learned kernel widening authorization: precisely what the gate-placement rule forbids a judge, and being a parent confers no exemption. The corollary is worth one sentence: a fully autonomous run is one whose root principal is unreachable, so the tree's only widening channel is closed and authorization is frozen at launch — not a limitation of the formalism but the honest price of the word *autonomous*.
|
||||
|
||||
The pattern generalizes, and that is the point of the appendix. Nothing here added a primitive: the cancel is a signal in $s$, the gate closes by the rule it already follows, the in-flight disposition is forced by irreversibility, $H_{\mathrm{cancel}}$ is a subclass of an existing terminal set, and compensation is an ordinary owner-issued action — and the later entries kept the promise: resume re-enters $T$ at a persisted $s$, the batch gate was always in $\gamma$'s domain, provenance closure is the lattice's meet, attenuation is narrow-only read along an edge, and per-action capability is the gate's decision made enforceable. Every practical concern that earns a place here should resolve the same way — not new machinery, but the discipline the existing objects already imply, made explicit. Cancellation and resume, gate placement and the batch gate, effect records and the state derived from them, composition and delegation — those are the worked instances; the rest of the model is the same exercise.
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -161,6 +161,35 @@ def test_rest_heals_and_charges(tmp_path: Path, clock: object) -> None:
|
||||
assert "full health" in out.lower()
|
||||
|
||||
|
||||
def test_rest_when_spent_restores_a_fresh_days_turns(tmp_path: Path, clock: object) -> None:
|
||||
"""Sleeping at the inn with no turns left rolls into a fresh day's allowance."""
|
||||
game = _game(tmp_path, clock)
|
||||
game.join("Brandr")
|
||||
player = game.players["Brandr"]
|
||||
daily = game.world.settings.daily_turns
|
||||
player.turns_left = 0 # spent for the day
|
||||
player.hp = 5
|
||||
game.move("Brandr", "", "west", 2) # step into the inn
|
||||
out = game.action("Brandr", "rest", "", "")
|
||||
assert player.turns_left == daily # a fresh day's turns restored
|
||||
assert player.hp == player.max_hp # and fully mended
|
||||
assert f"/{daily} ]" in out # footer reflects the refreshed budget
|
||||
|
||||
|
||||
def test_rest_with_turns_in_hand_never_inflates_the_budget(tmp_path: Path, clock: object) -> None:
|
||||
"""Resting mid-day mends but adds no turns — the top-up only fires at zero."""
|
||||
game = _game(tmp_path, clock)
|
||||
game.join("Brandr")
|
||||
player = game.players["Brandr"]
|
||||
daily = game.world.settings.daily_turns
|
||||
player.turns_left = daily - 3 # turns still in hand
|
||||
player.hp = 5
|
||||
game.move("Brandr", "", "west", 2) # step into the inn
|
||||
game.action("Brandr", "rest", "", "")
|
||||
assert player.turns_left == daily - 3 # unchanged: no farming past the cap
|
||||
assert player.hp == player.max_hp # but the heal still lands
|
||||
|
||||
|
||||
def test_fight_spends_a_turn_and_credits(tmp_path: Path, clock: object) -> None:
|
||||
game = _game(tmp_path, clock)
|
||||
game.join("Brandr")
|
||||
|
||||
@@ -1015,16 +1015,28 @@ class Game:
|
||||
return self._overworld_frame(player, lines=["You step back out into the open air."])
|
||||
|
||||
def _rest(self, player: Player) -> str:
|
||||
# Settle any pending day rollover first, so a rest taken as the first act
|
||||
# of a new day is the ordinary refresh, not the spent-turns top-up below.
|
||||
self._ensure_day(player)
|
||||
cost = self.world.settings.rest_cost
|
||||
if leveling.rest(player, cost):
|
||||
# A night's rest is a private errand, not Herald news; persist only.
|
||||
self._persist(player)
|
||||
if not leveling.rest(player, cost):
|
||||
return self._location_menu(
|
||||
player, lines=[f"You sleep deeply and wake at full health. (-{cost} gold)"]
|
||||
player, lines=[f"You can't afford the {cost}-gold bed. (You have {player.gold}.)"]
|
||||
)
|
||||
return self._location_menu(
|
||||
player, lines=[f"You can't afford the {cost}-gold bed. (You have {player.gold}.)"]
|
||||
)
|
||||
# A night at the inn always mends. Once the day's turns are spent it also
|
||||
# rolls the sleeper into a fresh day's allowance, so a spent adventurer can
|
||||
# press on rather than idling until the dawn rollover. The top-up only
|
||||
# fires at zero, so it never banks turns past the daily cap.
|
||||
line = f"You sleep deeply and wake at full health. (-{cost} gold)"
|
||||
if player.turns_left <= 0:
|
||||
player.turns_left = self.world.settings.daily_turns
|
||||
line = (
|
||||
f"You sleep through to a new dawn, waking at full health "
|
||||
f"and ready to venture out anew. (-{cost} gold)"
|
||||
)
|
||||
# A night's rest is a private errand, not Herald news; persist only.
|
||||
self._persist(player)
|
||||
return self._location_menu(player, lines=[line])
|
||||
|
||||
# -- the Vault: bank gold at the inn (safe from ambush) --------------
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"tier": 2,
|
||||
"name": "Goblin",
|
||||
"hp": 12,
|
||||
"atk": 5,
|
||||
"atk": 4,
|
||||
"def": 1,
|
||||
"xp": 18,
|
||||
"gold": 7
|
||||
@@ -30,7 +30,7 @@
|
||||
"tier": 2,
|
||||
"name": "Bandit Scout",
|
||||
"hp": 14,
|
||||
"atk": 6,
|
||||
"atk": 5,
|
||||
"def": 1,
|
||||
"xp": 20,
|
||||
"gold": 9
|
||||
@@ -50,7 +50,7 @@
|
||||
"tier": 3,
|
||||
"name": "Forest Wolf",
|
||||
"hp": 20,
|
||||
"atk": 8,
|
||||
"atk": 7,
|
||||
"def": 2,
|
||||
"xp": 35,
|
||||
"gold": 14
|
||||
@@ -59,7 +59,7 @@
|
||||
"tier": 3,
|
||||
"name": "Bog Stalker",
|
||||
"hp": 22,
|
||||
"atk": 9,
|
||||
"atk": 8,
|
||||
"def": 2,
|
||||
"xp": 38,
|
||||
"gold": 16
|
||||
@@ -79,7 +79,7 @@
|
||||
"tier": 4,
|
||||
"name": "Cave Troll",
|
||||
"hp": 38,
|
||||
"atk": 12,
|
||||
"atk": 11,
|
||||
"def": 4,
|
||||
"xp": 70,
|
||||
"gold": 30
|
||||
@@ -88,7 +88,7 @@
|
||||
"tier": 4,
|
||||
"name": "Barrow Wight",
|
||||
"hp": 35,
|
||||
"atk": 13,
|
||||
"atk": 12,
|
||||
"def": 4,
|
||||
"xp": 65,
|
||||
"gold": 28
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"=": {
|
||||
"key": "road",
|
||||
"glyph": "=",
|
||||
"glyph": "▒",
|
||||
"walkable": true,
|
||||
"encounter_rate": 0.02,
|
||||
"color": "road"
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
"settings": {
|
||||
"daily_turns": 10,
|
||||
"rest_cost": 15,
|
||||
"heal_cost_per_hp": 2,
|
||||
"starting_gold": 20,
|
||||
"heal_cost_per_hp": 1,
|
||||
"starting_gold": 37,
|
||||
"starting_weapon": "rusty_dagger",
|
||||
"starting_armor": "cloth_tunic",
|
||||
"start_hp": 20,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"tier": 2,
|
||||
"name": "Ember Imp",
|
||||
"hp": 12,
|
||||
"atk": 5,
|
||||
"atk": 4,
|
||||
"def": 1,
|
||||
"xp": 18,
|
||||
"gold": 7
|
||||
@@ -30,7 +30,7 @@
|
||||
"tier": 2,
|
||||
"name": "Slag Scuttler",
|
||||
"hp": 14,
|
||||
"atk": 6,
|
||||
"atk": 5,
|
||||
"def": 1,
|
||||
"xp": 20,
|
||||
"gold": 9
|
||||
@@ -50,7 +50,7 @@
|
||||
"tier": 3,
|
||||
"name": "Magma Hound",
|
||||
"hp": 20,
|
||||
"atk": 8,
|
||||
"atk": 7,
|
||||
"def": 2,
|
||||
"xp": 35,
|
||||
"gold": 14
|
||||
@@ -59,7 +59,7 @@
|
||||
"tier": 3,
|
||||
"name": "Obsidian Lurker",
|
||||
"hp": 22,
|
||||
"atk": 9,
|
||||
"atk": 8,
|
||||
"def": 2,
|
||||
"xp": 38,
|
||||
"gold": 16
|
||||
@@ -79,7 +79,7 @@
|
||||
"tier": 4,
|
||||
"name": "Basalt Golem",
|
||||
"hp": 38,
|
||||
"atk": 12,
|
||||
"atk": 11,
|
||||
"def": 4,
|
||||
"xp": 70,
|
||||
"gold": 30
|
||||
@@ -88,7 +88,7 @@
|
||||
"tier": 4,
|
||||
"name": "Ashen Wraith",
|
||||
"hp": 35,
|
||||
"atk": 13,
|
||||
"atk": 12,
|
||||
"def": 4,
|
||||
"xp": 65,
|
||||
"gold": 28
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"=": {
|
||||
"key": "basalt",
|
||||
"glyph": "=",
|
||||
"glyph": "▩",
|
||||
"walkable": true,
|
||||
"encounter_rate": 0.02,
|
||||
"color": "road"
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
"settings": {
|
||||
"daily_turns": 10,
|
||||
"rest_cost": 15,
|
||||
"heal_cost_per_hp": 2,
|
||||
"starting_gold": 20,
|
||||
"heal_cost_per_hp": 1,
|
||||
"starting_gold": 37,
|
||||
"starting_weapon": "charred_shiv",
|
||||
"starting_armor": "scorched_rags",
|
||||
"start_hp": 20,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.7.0a3"
|
||||
version = "1.7.0a6"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+863
-18
@@ -58,6 +58,46 @@ Attachments harness (/attachments/livepass.html): the composer attachment
|
||||
thumbnail crop/size, the native audio-control fit at the constrained
|
||||
height, the snippet contrast, and how a long filename behaves at the
|
||||
340px chip cap.
|
||||
Task-agent harness (/taskagent/livepass.html): the task_agent card — a task
|
||||
agent's sub-tool steps nested under its conversation row, driven through the
|
||||
REAL InteractivePane.handleEvent (parent tool_pending/tool_info -> child
|
||||
tool_pending/tool_result/tool_output_chunk/approve_request -> task_agent
|
||||
tool_result) so the SSE->card routing (_routeAgentItems / _ensureAgentCard,
|
||||
and appendToolOutput finding the nested row by call_id) is exercised, not
|
||||
just the leaf builders. Query flags: &theme=light; &collapsed=1 (all-auto,
|
||||
no approval -> the natural collapse-by-default state); ¶llel=1 (card in a
|
||||
2-tool batch, for the rail-bleed rules); &recall=1 (the RECALL path —
|
||||
replayHistory rebuilding the card from a /history `agent_steps` overlay, i.e.
|
||||
a reload while the ws is in memory); &expand=1 (open every card so a shot
|
||||
shows the nested steps); &race=1 (child steps emitted BEFORE the task_agent
|
||||
row paints — the parallel-pool ordering window; the orphan buffer must nest
|
||||
them rather than let them escape to top-level); &orphan=1 (child steps whose
|
||||
task_agent row NEVER paints — the safety valve must escape them to visible
|
||||
top-level rows after the grace window, stamping TASKAGENT-ORPHANS-ESCAPED-<n>,
|
||||
not leave them buffered/invisible). document.title stamps
|
||||
TASKAGENT-READY-<steps> on
|
||||
success, TASKAGENT-FAILED-... / TASKAGENT-ERROR when routing breaks, so a
|
||||
broken card can't screenshot green.
|
||||
|
||||
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
|
||||
events through pane.handleEvent/replayHistory with rAF yields, measuring:
|
||||
replayHistory wall time at N messages, live event-storm cost per turn on top
|
||||
of that transcript (reasoning/content deltas + tool batches + task_agent
|
||||
cards), tool_output_chunk throughput, busy/idle churn, heap + node count +
|
||||
_agentCards size across repeated replay cycles (leak probe), and longtask
|
||||
counts. Query params: ?n= (history size) &turns= &chunks= &cycles= &idle=
|
||||
&post=1 (POST the JSON report to /perf/report — the --perf runner captures
|
||||
it). Results land in <pre id="perf-json"> and document.title stamps
|
||||
PERF-READY-<n> / PERF-FAILED-<phase>. MEASUREMENT RULES: never run with
|
||||
--virtual-time-budget (it corrupts performance.now) and never pass
|
||||
--force-prefers-reduced-motion (it disables the animations whose cost we
|
||||
measure); the --perf runner passes --js-flags=--expose-gc and
|
||||
--enable-precise-memory-info so heap numbers are stable and real.
|
||||
|
||||
python3 scripts/livepass.py --perf # 300 and 3000 msgs
|
||||
python3 scripts/livepass.py --perf --perf-n 5000 # match the field run
|
||||
|
||||
Rebuild after ANY markup change: the dialog blocks are embedded at build
|
||||
time. Assets are symlinked, so CSS/JS edits are live on refresh.
|
||||
@@ -66,7 +106,13 @@ time. Assets are symlinked, so CSS/JS edits are live on refresh.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.server
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -767,6 +813,553 @@ ATTACH_TEMPLATE = """<!doctype html>
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Task-agent harness — the task_agent card: a task agent's sub-tool steps
|
||||
# nested under its conversation row. Driven through the REAL
|
||||
# InteractivePane.handleEvent so the SSE->card ROUTING (_routeAgentItems /
|
||||
# _ensureAgentCard, plus appendToolOutput finding the nested row by call_id)
|
||||
# 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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>task_agent 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 — task_agent card (real InteractivePane.handleEvent)</div>
|
||||
<div class="messages" id="messages"></div>
|
||||
</div>
|
||||
<script>
|
||||
// interactive.js reads window.toast / window.authFetch; the static render
|
||||
// never POSTs, so no-op stubs are enough.
|
||||
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(""); },
|
||||
});
|
||||
};
|
||||
</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 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 = () => {};
|
||||
const ev = (e) => pane.handleEvent(e);
|
||||
|
||||
// ?recall=1: exercise the RECALL path — replayHistory rebuilding the
|
||||
// card from the /history `agent_steps` overlay (a reload / reopen while
|
||||
// the ws is still in memory), as opposed to the live SSE path below.
|
||||
const recall = q.get("recall") === "1";
|
||||
if (recall) {
|
||||
pane.replayHistory([
|
||||
{ role: "user", content: "Find all call sites of resolve_alias and summarize them" },
|
||||
{ role: "assistant", tool_calls: [{
|
||||
name: "task_agent", id: "task1",
|
||||
arguments: JSON.stringify({ prompt: "Find call sites of resolve_alias" }),
|
||||
agent_steps: [
|
||||
{ id: "task1::c1", name: "search", arguments: JSON.stringify({ query: "resolve_alias" }), output: "12 matches across 4 files", is_error: false },
|
||||
{ id: "task1::c2", name: "read_file", arguments: JSON.stringify({ path: "core/registry.py" }), output: "4.1 KB read", is_error: false },
|
||||
{ id: "task1::c3", name: "bash", arguments: JSON.stringify({ command: "pytest -k registry" }), output: "12 passed in 1.2s", is_error: false },
|
||||
{ id: "task1::c4", name: "notify", arguments: JSON.stringify({ channel: "#eng", message: "post summary" }), output: "posted to #eng", is_error: false },
|
||||
],
|
||||
}] },
|
||||
{ role: "tool", tool_call_id: "task1", content: "resolve_alias has 4 call sites (registry.py:120, session.py:12200, model_registry.py:88, eval.py:54); all pass a validated alias before use." },
|
||||
]);
|
||||
} else if (q.get("race") === "1") {
|
||||
// ?race=1: reproduce the parallel-pool ordering window — each
|
||||
// sub-tool's tool_pending is emitted exactly once (as in production)
|
||||
// but AHEAD of the task_agent row paint, as happens when a pooled
|
||||
// sub-agent's SSE event is handled before its parent row commits.
|
||||
// The orphan buffer must hold them and nest them when the parent row
|
||||
// lands; pre-fix they escaped to top-level rows and the card came up
|
||||
// short (steps < 4 -> TASKAGENT-FAILED), so this can't screenshot
|
||||
// green without the fix.
|
||||
const raceTask = {
|
||||
call_id: "task1", func_name: "task_agent",
|
||||
header: 'task_agent: "Find all call sites of resolve_alias and summarize them"',
|
||||
needs_approval: false,
|
||||
};
|
||||
const childPending = (cid, fn, header) =>
|
||||
ev({ type: "tool_pending", items: [{ call_id: cid, parent_call_id: "task1", func_name: fn, header: header, needs_approval: false }] });
|
||||
// a) Orphan child pendings arrive first — no parent row yet.
|
||||
childPending("task1::c1", "search", 'search: "resolve_alias"');
|
||||
childPending("task1::c2", "read_file", "read_file: core/registry.py");
|
||||
childPending("task1::c3", "bash", "pytest -k registry");
|
||||
childPending("task1::c4", "notify", "notify: post summary to #eng");
|
||||
// b) Parent task_agent row paints (pending -> resolved): must flush the
|
||||
// buffered orphans into the card AND survive the upgrade rebuild.
|
||||
ev({ type: "tool_pending", items: [raceTask] });
|
||||
ev({ type: "tool_info", items: [Object.assign({ auto_approved: false }, raceTask)] });
|
||||
// c) Results + a streamed chunk follow, nesting into the flushed rows.
|
||||
ev({ type: "tool_result", call_id: "task1::c1", parent_call_id: "task1", name: "search", output: "12 matches across 4 files" });
|
||||
ev({ type: "tool_result", call_id: "task1::c2", parent_call_id: "task1", name: "read_file", output: "4.1 KB read" });
|
||||
ev({ type: "tool_output_chunk", call_id: "task1::c3", parent_call_id: "task1", chunk: "collected 12 items ... " });
|
||||
ev({ type: "tool_result", call_id: "task1::c3", parent_call_id: "task1", name: "bash", output: "12 passed in 1.2s" });
|
||||
ev({ type: "tool_result", call_id: "task1::c4", parent_call_id: "task1", name: "notify", output: "posted to #eng" });
|
||||
ev({ type: "tool_result", call_id: "task1", name: "task_agent", output: "resolve_alias has 4 call sites (registry.py:120, session.py:12200, model_registry.py:88, eval.py:54); all pass a validated alias before use." });
|
||||
} else if (q.get("orphan") === "1") {
|
||||
// ?orphan=1: the SAFETY VALVE — child steps whose task_agent row
|
||||
// NEVER paints (an id-correlation mismatch, or an agent aborted
|
||||
// before its row painted). They must not vanish: after the grace
|
||||
// window the buffer escapes them to visible top-level rows (the
|
||||
// pre-buffer behaviour) rather than holding them forever. The parent
|
||||
// task_agent row is deliberately never emitted here.
|
||||
const orphanPending = (cid, fn, header) =>
|
||||
ev({ type: "tool_pending", items: [{ call_id: cid, parent_call_id: "task1", func_name: fn, header: header, needs_approval: false }] });
|
||||
orphanPending("task1::c1", "search", 'search: "resolve_alias"');
|
||||
orphanPending("task1::c2", "read_file", "read_file: core/registry.py");
|
||||
orphanPending("task1::c3", "bash", "pytest -k registry");
|
||||
} else {
|
||||
|
||||
// 1. Parent paints the task_agent call (a top-level tool row).
|
||||
const taskItem = {
|
||||
call_id: "task1", func_name: "task_agent",
|
||||
header: 'task_agent: "Find all call sites of resolve_alias and summarize them"',
|
||||
needs_approval: false,
|
||||
};
|
||||
// ?parallel=1 puts the task_agent in a 2-tool parallel batch so the
|
||||
// nested-step rail-bleed fix can be verified against the rail rules.
|
||||
const parentItems = q.get("parallel") === "1"
|
||||
? [taskItem, { call_id: "sib1", func_name: "bash", header: "git status", needs_approval: false }]
|
||||
: [taskItem];
|
||||
ev({ type: "tool_pending", items: parentItems });
|
||||
ev({ type: "tool_info", items: parentItems.map((it) => Object.assign({ auto_approved: false }, it)) });
|
||||
if (parentItems.length > 1)
|
||||
ev({ type: "tool_result", call_id: "sib1", name: "bash", output: "clean" });
|
||||
|
||||
// 2. Sub-agent steps tagged parent_call_id="task1" — exercises routing.
|
||||
function stepRow(cid, fn, header, result) {
|
||||
ev({ type: "tool_pending", items: [{ call_id: cid, parent_call_id: "task1", func_name: fn, header: header, needs_approval: false }] });
|
||||
if (result != null)
|
||||
ev({ type: "tool_result", call_id: cid, parent_call_id: "task1", name: fn, output: result });
|
||||
}
|
||||
stepRow("task1::c1", "search", 'search: "resolve_alias"', "12 matches across 4 files");
|
||||
stepRow("task1::c2", "read_file", "read_file: core/registry.py", "4.1 KB read");
|
||||
ev({ type: "tool_pending", items: [{ call_id: "task1::c3", parent_call_id: "task1", func_name: "bash", header: "pytest -k registry", needs_approval: false }] });
|
||||
ev({ type: "tool_output_chunk", call_id: "task1::c3", parent_call_id: "task1", chunk: "collected 12 items ... " });
|
||||
ev({ type: "tool_result", call_id: "task1::c3", parent_call_id: "task1", name: "bash", output: "12 passed in 1.2s" });
|
||||
// 4th step. Default: a nested sub-tool approval (notify is not
|
||||
// auto-approved) — the pane must auto-expand the collapse-by-default
|
||||
// card so the blocking prompt is visible. ?collapsed=1: a plain
|
||||
// completed step instead, so nothing forces the card open and the
|
||||
// screenshot shows the natural collapsed state (the common case).
|
||||
if (q.get("collapsed") === "1") {
|
||||
stepRow("task1::c4", "notify", "notify: post summary to #eng", "posted to #eng");
|
||||
} else {
|
||||
ev({ type: "approve_request", judge_pending: false, items: [{ call_id: "task1::c4", parent_call_id: "task1", func_name: "notify", header: "notify: post summary to #eng", needs_approval: true }] });
|
||||
}
|
||||
|
||||
// 3. The task agent's own synthesis, rendered below the card.
|
||||
ev({ type: "tool_result", call_id: "task1", name: "task_agent", output: "resolve_alias has 4 call sites (registry.py:120, session.py:12200, model_registry.py:88, eval.py:54); all pass a validated alias before use." });
|
||||
}
|
||||
|
||||
// ?expand=1: open every card so a screenshot shows the nested steps
|
||||
// (cards collapse by default; recall has no approval to auto-expand).
|
||||
if (q.get("expand") === "1") {
|
||||
document.querySelectorAll(".conv-agent").forEach(function (c) {
|
||||
c.dataset.collapsed = "false";
|
||||
const t = c.querySelector(".conv-agent-toggle");
|
||||
if (t) t.setAttribute("aria-expanded", "true");
|
||||
});
|
||||
}
|
||||
|
||||
// Loud failure — broken routing must not screenshot green.
|
||||
const orphanMode = q.get("orphan") === "1";
|
||||
setTimeout(function () {
|
||||
if (orphanMode) {
|
||||
// The parent never painted; after the grace window the buffered
|
||||
// steps must have ESCAPED to visible top-level rows, not vanished.
|
||||
const escaped = document.querySelectorAll('.conv-batch .conv-row[data-call-id^="task1::"]').length;
|
||||
const leaked = document.querySelector('.conv-row[data-call-id="task1"] .conv-agent');
|
||||
document.title = escaped >= 3 && !leaked
|
||||
? "TASKAGENT-ORPHANS-ESCAPED-" + escaped
|
||||
: "TASKAGENT-FAILED-escaped" + escaped + "-card" + (leaked ? 1 : 0);
|
||||
return;
|
||||
}
|
||||
const row = document.querySelector('.conv-row[data-call-id="task1"]');
|
||||
const card = row && row.querySelector(".conv-agent");
|
||||
const steps = card ? card.querySelectorAll(".conv-agent-body .conv-row").length : 0;
|
||||
const hasResult = !!(row && /call sites/.test(row.textContent || ""));
|
||||
document.title = card && steps >= 4 && hasResult
|
||||
? "TASKAGENT-READY-" + steps
|
||||
: "TASKAGENT-FAILED-card" + (card ? 1 : 0) + "-steps" + steps + "-result" + (hasResult ? 1 : 0);
|
||||
}, orphanMode ? 900 : 300);
|
||||
} catch (e) {
|
||||
messages.textContent = "HARNESS ERROR: " + e.message + "\\n" + (e.stack || "");
|
||||
document.title = "TASKAGENT-ERROR";
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Perf harness — long-session performance baseline for the interactive pane.
|
||||
# Mounts the REAL InteractivePane (production DOM via _createDOM, production
|
||||
# CSS chain) in a fixed-height mount so .pane-messages has REAL scroll
|
||||
# geometry — the forced-layout costs under measurement (isNearBottom /
|
||||
# scrollToBottom / chunk-append scroll pins) only exist against live layout,
|
||||
# which is why nothing here stubs scroll/geometry the way the task-agent
|
||||
# harness does. All timing is real time (see MEASUREMENT RULES in the module
|
||||
# docstring). Workload is deterministic (seeded LCG) so runs are comparable.
|
||||
# --------------------------------------------------------------------------
|
||||
PERF_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>perf 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="static/style.css" />
|
||||
<link rel="stylesheet" href="shared/interactive.css" />
|
||||
<style>
|
||||
/* Harness-only framing (NOT under review): a fixed-height mount so the
|
||||
pane's .pane-messages scroller has real production geometry. */
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); }
|
||||
#mount { height: 720px; width: 920px; display: flex; overflow: hidden; }
|
||||
#mount > .pane { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
#perf-json { font: 11px monospace; white-space: pre-wrap; padding: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="mount"></div>
|
||||
<pre id="perf-json">running…</pre>
|
||||
<script>
|
||||
window.toast = { error: function (m) { console.log("toast:", m); } };
|
||||
// Collect every uncaught error/rejection into the report — a perf run
|
||||
// that silently swallowed a pipeline exception must not read as clean.
|
||||
window.__perfErrors = [];
|
||||
window.onerror = function (msg, src, line) {
|
||||
window.__perfErrors.push(String(msg) + " @ " + (src || "?") + ":" + (line || 0));
|
||||
};
|
||||
window.addEventListener("unhandledrejection", function (e) {
|
||||
window.__perfErrors.push("unhandledrejection: " + String(e && e.reason));
|
||||
});
|
||||
window.__perfFetch = function () {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
json: function () { return Promise.resolve({}); },
|
||||
text: function () { return Promise.resolve(""); },
|
||||
});
|
||||
};
|
||||
window.authFetch = window.__perfFetch;
|
||||
</script>
|
||||
<script type="module">
|
||||
import { InteractivePane } from "./shared/interactive.js";
|
||||
// auth.js's legacy window bridge clobbers window.authFetch at module
|
||||
// import time — reinstate the stub now imports have evaluated (same
|
||||
// dance as the attachments harness).
|
||||
window.authFetch = window.__perfFetch;
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const N = parseInt(q.get("n") || "1000", 10);
|
||||
const TURNS = parseInt(q.get("turns") || "20", 10);
|
||||
const CHUNKS = parseInt(q.get("chunks") || "300", 10);
|
||||
const CYCLES = parseInt(q.get("cycles") || "3", 10);
|
||||
const IDLE = parseInt(q.get("idle") || "20", 10);
|
||||
|
||||
// Long-task accounting across every phase (>50ms main-thread blocks).
|
||||
const lt = { count: 0, total_ms: 0, max_ms: 0 };
|
||||
try {
|
||||
new PerformanceObserver(function (list) {
|
||||
list.getEntries().forEach(function (e) {
|
||||
lt.count += 1;
|
||||
lt.total_ms += Math.round(e.duration);
|
||||
lt.max_ms = Math.max(lt.max_ms, Math.round(e.duration));
|
||||
});
|
||||
}).observe({ type: "longtask", buffered: true });
|
||||
} catch (e) { /* unsupported — longtasks stay zeroed */ }
|
||||
|
||||
// Deterministic workload (seeded LCG) so runs are comparable.
|
||||
let _seed = 42;
|
||||
function rnd() {
|
||||
_seed = (_seed * 1664525 + 1013904223) >>> 0;
|
||||
return _seed / 4294967296;
|
||||
}
|
||||
const WORDS = ("the retry loop grinds the dungeon server while the " +
|
||||
"judge weighs verdicts and the coordinator shuffles children across " +
|
||||
"nodes tokens accumulate compaction folds turns storage keeps the " +
|
||||
"canon and the rail repaints").split(" ");
|
||||
function sentence(w) {
|
||||
const parts = [];
|
||||
for (let i = 0; i < w; i++) parts.push(WORDS[(rnd() * WORDS.length) | 0]);
|
||||
return parts.join(" ");
|
||||
}
|
||||
// Realistic assistant markdown: prose + list + fenced code (varying
|
||||
// content so the hljs cache behaves as in production) + inline code.
|
||||
function mdBody(i) {
|
||||
return (
|
||||
"Turn " + i + ": " + sentence(18) + ".\\n\\n" +
|
||||
"- " + sentence(6) + "\\n- " + sentence(7) + "\\n\\n" +
|
||||
"```python\\n" +
|
||||
"def step_" + i + "(depth):\\n" +
|
||||
" total = " + ((rnd() * 1000) | 0) + "\\n" +
|
||||
" for k in range(depth):\\n" +
|
||||
" total += k * " + (1 + ((rnd() * 9) | 0)) + "\\n" +
|
||||
" return total\\n" +
|
||||
"```\\n\\n" +
|
||||
sentence(14) + " `inline_" + i + "` " + sentence(8) + "."
|
||||
);
|
||||
}
|
||||
// History in the canonical projected wire shape replayHistory consumes
|
||||
// (user / assistant content / assistant tool_calls / tool result), with
|
||||
// periodic reasoning bubbles and task_agent cards (agent_steps overlay).
|
||||
function buildHistory(n) {
|
||||
const msgs = [];
|
||||
let i = 0;
|
||||
while (msgs.length < n) {
|
||||
i += 1;
|
||||
msgs.push({ role: "user", content: "Request " + i + ": " + sentence(10) + "?" });
|
||||
if (msgs.length >= n) break;
|
||||
if (i % 10 === 0) {
|
||||
msgs.push({ role: "assistant", reasoning: sentence(40) + ".", content: mdBody(i) });
|
||||
} else {
|
||||
msgs.push({ role: "assistant", content: mdBody(i) });
|
||||
}
|
||||
if (msgs.length >= n) break;
|
||||
const callId = "h" + i;
|
||||
if (i % 8 === 0) {
|
||||
msgs.push({ role: "assistant", tool_calls: [{
|
||||
name: "task_agent", id: callId,
|
||||
arguments: JSON.stringify({ prompt: "subtask " + i }),
|
||||
agent_steps: [
|
||||
{ id: callId + "::c1", name: "search",
|
||||
arguments: JSON.stringify({ query: "q" + i }),
|
||||
output: sentence(8), is_error: false },
|
||||
{ id: callId + "::c2", name: "read_file",
|
||||
arguments: JSON.stringify({ path: "core/f" + i + ".py" }),
|
||||
output: sentence(6), is_error: false },
|
||||
{ id: callId + "::c3", name: "bash",
|
||||
arguments: JSON.stringify({ command: "pytest -k t" + i }),
|
||||
output: sentence(7), is_error: false },
|
||||
],
|
||||
}] });
|
||||
} else {
|
||||
msgs.push({ role: "assistant", tool_calls: [{
|
||||
name: "bash", id: callId,
|
||||
arguments: JSON.stringify({ command: "grep -rn pattern_" + i + " src/" }),
|
||||
}] });
|
||||
}
|
||||
if (msgs.length >= n) break;
|
||||
msgs.push({ role: "tool", tool_call_id: callId,
|
||||
content: "output " + i + ":\\n" + sentence(20) });
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => requestAnimationFrame(r));
|
||||
// One live turn, production event mix: thinking indicator, reasoning
|
||||
// deltas, content deltas (yield every few so streamingRender's internal
|
||||
// rAF actually applies frames, as in a real token stream), stream_end,
|
||||
// an auto-approved bash batch with streamed chunks, every 5th turn a
|
||||
// task_agent card with routed children, then the idle edge.
|
||||
async function stormTurn(pane, i) {
|
||||
pane.handleEvent({ type: "state_change", state: "running" });
|
||||
pane.handleEvent({ type: "thinking_start" });
|
||||
const reason = sentence(50);
|
||||
let d = 0;
|
||||
for (let k = 0; k < reason.length; k += 20) {
|
||||
pane.handleEvent({ type: "reasoning", text: reason.slice(k, k + 20) });
|
||||
d += 1;
|
||||
if (d % 4 === 3) await tick();
|
||||
}
|
||||
const body = mdBody(100000 + i);
|
||||
d = 0;
|
||||
for (let k = 0; k < body.length; k += 22) {
|
||||
pane.handleEvent({ type: "content", text: body.slice(k, k + 22) });
|
||||
d += 1;
|
||||
if (d % 6 === 5) await tick();
|
||||
}
|
||||
pane.handleEvent({ type: "stream_end" });
|
||||
const callId = "s" + i;
|
||||
const item = { call_id: callId, func_name: "bash",
|
||||
header: "bash: run step " + i, needs_approval: false };
|
||||
pane.handleEvent({ type: "tool_pending", items: [item] });
|
||||
pane.handleEvent({ type: "tool_info",
|
||||
items: [Object.assign({ auto_approved: true }, item)] });
|
||||
for (let k = 0; k < 24; k++) {
|
||||
pane.handleEvent({ type: "tool_output_chunk", call_id: callId,
|
||||
chunk: "line " + k + ": " + sentence(5) + "\\n" });
|
||||
if (k % 6 === 5) await tick();
|
||||
}
|
||||
pane.handleEvent({ type: "tool_result", call_id: callId, name: "bash",
|
||||
output: "done " + i + "\\n" + sentence(12) });
|
||||
if (i % 5 === 4) {
|
||||
const tid = "sa" + i;
|
||||
const titem = { call_id: tid, func_name: "task_agent",
|
||||
header: 'task_agent: "subtask ' + i + '"', needs_approval: false };
|
||||
pane.handleEvent({ type: "tool_pending", items: [titem] });
|
||||
pane.handleEvent({ type: "tool_info",
|
||||
items: [Object.assign({ auto_approved: true }, titem)] });
|
||||
for (let c = 1; c <= 3; c++) {
|
||||
const cid = tid + "::c" + c;
|
||||
pane.handleEvent({ type: "tool_pending", items: [{
|
||||
call_id: cid, parent_call_id: tid, func_name: "search",
|
||||
header: "search: q" + c, needs_approval: false }] });
|
||||
pane.handleEvent({ type: "tool_result", call_id: cid,
|
||||
parent_call_id: tid, name: "search", output: sentence(6) });
|
||||
}
|
||||
pane.handleEvent({ type: "tool_result", call_id: tid,
|
||||
name: "task_agent", output: sentence(15) });
|
||||
await tick();
|
||||
}
|
||||
pane.handleEvent({ type: "state_change", state: "idle" });
|
||||
await tick();
|
||||
}
|
||||
|
||||
function heapBytes() {
|
||||
// --js-flags=--expose-gc makes this a real floor, not GC noise.
|
||||
if (typeof window.gc === "function") {
|
||||
try { window.gc(); window.gc(); } catch (e) { /* noop */ }
|
||||
}
|
||||
return (performance.memory && performance.memory.usedJSHeapSize) || null;
|
||||
}
|
||||
|
||||
const report = {
|
||||
n: N, turns: TURNS, chunks: CHUNKS, cycles: CYCLES, idle: IDLE,
|
||||
// Echoed run token — the runner validates it so a straggler POST
|
||||
// from a killed prior attempt can't be misattributed to this run.
|
||||
run: q.get("run") || "",
|
||||
errors: window.__perfErrors,
|
||||
};
|
||||
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();
|
||||
|
||||
phase = "replay";
|
||||
let t0 = performance.now();
|
||||
pane.replayHistory(msgs);
|
||||
report.replay_ms = Math.round(performance.now() - t0);
|
||||
await tick();
|
||||
report.nodes_after_replay = pane.messagesEl.querySelectorAll("*").length;
|
||||
|
||||
phase = "storm";
|
||||
t0 = performance.now();
|
||||
for (let i = 0; i < TURNS; i++) await stormTurn(pane, i);
|
||||
report.storm_ms = Math.round(performance.now() - t0);
|
||||
report.storm_ms_per_turn = Math.round(report.storm_ms / TURNS);
|
||||
|
||||
phase = "chunkstorm";
|
||||
const ccItem = { call_id: "cc1", func_name: "bash",
|
||||
header: "bash: tail -f build.log", needs_approval: false };
|
||||
pane.handleEvent({ type: "tool_pending", items: [ccItem] });
|
||||
pane.handleEvent({ type: "tool_info",
|
||||
items: [Object.assign({ auto_approved: true }, ccItem)] });
|
||||
t0 = performance.now();
|
||||
for (let k = 0; k < CHUNKS; k++) {
|
||||
pane.handleEvent({ type: "tool_output_chunk", call_id: "cc1",
|
||||
chunk: "log line " + k + "\\n" });
|
||||
if (k % 6 === 5) await tick();
|
||||
}
|
||||
report.chunk_ms = Math.round(performance.now() - t0);
|
||||
pane.handleEvent({ type: "tool_result", call_id: "cc1", name: "bash",
|
||||
output: "tail done" });
|
||||
|
||||
phase = "idlechurn";
|
||||
t0 = performance.now();
|
||||
for (let k = 0; k < IDLE; k++) {
|
||||
pane.handleEvent({ type: "state_change", state: "running" });
|
||||
pane.handleEvent({ type: "state_change", state: "idle" });
|
||||
if (k % 4 === 3) await tick();
|
||||
}
|
||||
report.idle_ms = Math.round(performance.now() - t0);
|
||||
|
||||
// Leak probe: repeated full replays of the SAME history should
|
||||
// converge to a flat heap/node/agent-card profile; monotonic growth
|
||||
// here is retained-detached-DOM (the _agentCards class of bug).
|
||||
phase = "replaycycles";
|
||||
report.cycle_stats = [];
|
||||
for (let c = 0; c < CYCLES; c++) {
|
||||
t0 = performance.now();
|
||||
pane.replayHistory(msgs);
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
await tick();
|
||||
report.cycle_stats.push({
|
||||
replay_ms: ms,
|
||||
heap: heapBytes(),
|
||||
nodes: pane.messagesEl.querySelectorAll("*").length,
|
||||
agent_cards: pane._agentCards ? pane._agentCards.size : 0,
|
||||
});
|
||||
}
|
||||
report.heap_end = heapBytes();
|
||||
report.longtasks = lt;
|
||||
document.title = "PERF-READY-" + N;
|
||||
} catch (e) {
|
||||
window.__perfErrors.push(
|
||||
"phase " + phase + ": " + (e && e.message ? e.message : String(e)),
|
||||
);
|
||||
report.failed_phase = phase;
|
||||
report.longtasks = lt;
|
||||
document.title = "PERF-FAILED-" + phase;
|
||||
}
|
||||
document.getElementById("perf-json").textContent =
|
||||
JSON.stringify(report, null, 2);
|
||||
if (q.get("post")) {
|
||||
try {
|
||||
await fetch("/perf/report", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(report),
|
||||
});
|
||||
} catch (e) { /* runner captures the timeout instead */ }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# Fixture media for the attachments harness. image/pdf thumbnails and the
|
||||
# audio clip load via element .src (NOT authFetch), so the --serve dev server
|
||||
# answers those paths directly with representative bytes: a photo-like image,
|
||||
@@ -883,34 +1476,286 @@ def build(out: Path) -> None:
|
||||
(att / "livepass.html").write_text(ATTACH_TEMPLATE, encoding="utf-8")
|
||||
print(f"{att}/livepass.html — composer chips + message attachment pills")
|
||||
|
||||
ta = out / "taskagent"
|
||||
ta.mkdir(parents=True, exist_ok=True)
|
||||
symlink(ta / "shared", ROOT / "turnstone/shared_static")
|
||||
(ta / "livepass.html").write_text(TASKAGENT_TEMPLATE, encoding="utf-8")
|
||||
print(f"{ta}/livepass.html — task_agent card (real Pane.handleEvent routing)")
|
||||
|
||||
pf = out / "perf"
|
||||
pf.mkdir(parents=True, exist_ok=True)
|
||||
symlink(pf / "shared", ROOT / "turnstone/shared_static")
|
||||
symlink(pf / "static", ROOT / "turnstone/ui/static")
|
||||
(pf / "livepass.html").write_text(PERF_TEMPLATE, encoding="utf-8")
|
||||
print(f"{pf}/livepass.html — long-session perf baseline (real InteractivePane)")
|
||||
|
||||
|
||||
class _PerfStore:
|
||||
"""Rendezvous for the perf page's POSTed JSON report."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
import threading
|
||||
|
||||
self.event = threading.Event()
|
||||
self.data: dict[str, object] | None = None
|
||||
|
||||
|
||||
class _HarnessHandler(http.server.SimpleHTTPRequestHandler):
|
||||
"""Static file server + attachment media fixtures + perf-report sink.
|
||||
|
||||
The attachments harness loads thumbnails + the audio clip via element
|
||||
.src; serve those from generated fixtures, fall through to static for
|
||||
everything else. The perf harness POSTs its JSON report to /perf/report
|
||||
when driven with ?post=1 — the --perf runner blocks on ``perf_store``.
|
||||
"""
|
||||
|
||||
perf_store: _PerfStore | None = None
|
||||
quiet = False
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 (stdlib casing)
|
||||
blob = _fixture_for(self.path.split("?")[0])
|
||||
if blob is None:
|
||||
super().do_GET()
|
||||
return
|
||||
data, ctype = blob
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 (stdlib casing)
|
||||
store = type(self).perf_store
|
||||
if self.path.split("?")[0] != "/perf/report" or store is None:
|
||||
self.send_error(404)
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
store.data = json.loads(body)
|
||||
except ValueError:
|
||||
store.data = {"errors": ["runner: unparseable report body"]}
|
||||
store.event.set()
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A002 (stdlib signature)
|
||||
if not type(self).quiet:
|
||||
super().log_message(format, *args)
|
||||
|
||||
|
||||
def _find_chrome() -> str | None:
|
||||
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _await_report(
|
||||
store: _PerfStore, proc: subprocess.Popen[bytes], run_token: str, timeout: float
|
||||
) -> dict[str, object] | None:
|
||||
"""Wait for THIS attempt's report: validated by run token, bailing early
|
||||
when Chrome exits without reporting (the sandbox-startup-failure case —
|
||||
waiting the full timeout there cost minutes before the --no-sandbox
|
||||
fallback could even start). A straggler POST from a previous attempt
|
||||
(its handler thread can complete after the next attempt cleared the
|
||||
store) carries the wrong token and is discarded instead of being
|
||||
misattributed to this run."""
|
||||
deadline = time.monotonic() + timeout
|
||||
proc_exited_at: float | None = None
|
||||
while time.monotonic() < deadline:
|
||||
if store.event.wait(0.5):
|
||||
data = store.data
|
||||
store.event.clear()
|
||||
store.data = None
|
||||
if isinstance(data, dict) and data.get("run") == run_token:
|
||||
return data
|
||||
continue # stale straggler from a prior attempt — keep waiting
|
||||
if proc.poll() is not None:
|
||||
now = time.monotonic()
|
||||
if proc_exited_at is None:
|
||||
proc_exited_at = now # grace: an in-flight POST may still land
|
||||
elif now - proc_exited_at > 3.0:
|
||||
return None # exited without reporting — try the next attempt
|
||||
return None
|
||||
|
||||
|
||||
def _perf_run_one(
|
||||
chrome: str,
|
||||
out: Path,
|
||||
port: int,
|
||||
store: _PerfStore,
|
||||
n: int,
|
||||
turns: int,
|
||||
timeout: float,
|
||||
extra_query: str = "",
|
||||
) -> dict[str, object] | None:
|
||||
"""One headless-Chrome perf pass; returns the page's report or None."""
|
||||
base_flags = [
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--hide-scrollbars",
|
||||
"--window-size=1440,900",
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
# Throttled timers/rAF in a backgrounded renderer would corrupt the
|
||||
# measurement — pin the renderer foreground-scheduled.
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
# Stable, real heap numbers (heapBytes() calls window.gc() first).
|
||||
"--js-flags=--expose-gc",
|
||||
"--enable-precise-memory-info",
|
||||
]
|
||||
for attempt, extra in enumerate(
|
||||
([], ["--no-sandbox"]) # sandboxed first, container fallback second
|
||||
):
|
||||
run_token = f"n{n}-a{attempt}-{uuid.uuid4().hex[:8]}"
|
||||
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}"
|
||||
proc = subprocess.Popen(
|
||||
[chrome, *base_flags, *extra, f"--user-data-dir={profile}", url],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
report = _await_report(store, proc, run_token, timeout)
|
||||
if report is not None:
|
||||
return report
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
return None
|
||||
|
||||
|
||||
def run_perf(
|
||||
out: Path, sizes: list[int], turns: int, timeout: float, extra_query: str = ""
|
||||
) -> bool:
|
||||
"""Build, serve, and run the perf page once per history size; print a table."""
|
||||
import functools
|
||||
import threading
|
||||
|
||||
chrome = _find_chrome()
|
||||
if chrome is None:
|
||||
print("perf: no chrome/chromium binary found on PATH")
|
||||
return False
|
||||
store = _PerfStore()
|
||||
_HarnessHandler.perf_store = store
|
||||
_HarnessHandler.quiet = True
|
||||
handler = functools.partial(_HarnessHandler, directory=str(out))
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
port = server.server_address[1]
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
reports: dict[int, dict[str, object]] = {}
|
||||
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)
|
||||
if report is None:
|
||||
print("FAILED (no report — timeout or chrome startup failure)")
|
||||
continue
|
||||
failed = report.get("failed_phase")
|
||||
errors = report.get("errors") or []
|
||||
status = f"failed in {failed}" if failed else "ok"
|
||||
print(f"{status} ({len(errors) if isinstance(errors, list) else '?'} page errors)")
|
||||
reports[n] = report
|
||||
(out / f"perf-report-n{n}.json").write_text(
|
||||
json.dumps(report, indent=2), encoding="utf-8"
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
_HarnessHandler.perf_store = None
|
||||
_HarnessHandler.quiet = False
|
||||
if not reports:
|
||||
return False
|
||||
_print_perf_table(reports)
|
||||
print(f"\nraw reports: {out}/perf-report-n*.json")
|
||||
return True
|
||||
|
||||
|
||||
def _print_perf_table(reports: dict[int, dict[str, object]]) -> None:
|
||||
sizes = sorted(reports)
|
||||
|
||||
def cell(n: int, key: str) -> str:
|
||||
value = reports[n].get(key)
|
||||
return "—" if value is None else str(value)
|
||||
|
||||
def mb(value: object) -> str:
|
||||
return f"{value / 1048576:.1f}MB" if isinstance(value, (int, float)) else "—"
|
||||
|
||||
rows: list[tuple[str, list[str]]] = [
|
||||
("replay_ms (full history build)", [cell(n, "replay_ms") for n in sizes]),
|
||||
("nodes after replay", [cell(n, "nodes_after_replay") for n in sizes]),
|
||||
("storm ms/turn (live mix)", [cell(n, "storm_ms_per_turn") for n in sizes]),
|
||||
("chunk_ms (output chunks)", [cell(n, "chunk_ms") for n in sizes]),
|
||||
("idle_ms (busy/idle churn)", [cell(n, "idle_ms") for n in sizes]),
|
||||
("heap start → end", []),
|
||||
("longtasks count/max_ms", []),
|
||||
("replay cycles ms", []),
|
||||
("agent_cards after cycles", []),
|
||||
]
|
||||
for n in sizes:
|
||||
rep = reports[n]
|
||||
rows[5][1].append(f"{mb(rep.get('heap_start'))} → {mb(rep.get('heap_end'))}")
|
||||
lt = rep.get("longtasks")
|
||||
rows[6][1].append(f"{lt.get('count')}/{lt.get('max_ms')}" if isinstance(lt, dict) else "—")
|
||||
cycles = rep.get("cycle_stats")
|
||||
if isinstance(cycles, list) and cycles:
|
||||
rows[7][1].append(",".join(str(c.get("replay_ms", "?")) for c in cycles))
|
||||
rows[8][1].append(str(cycles[-1].get("agent_cards", "?")))
|
||||
else:
|
||||
rows[7][1].append("—")
|
||||
rows[8][1].append("—")
|
||||
|
||||
label_w = max(len(label) for label, _ in rows)
|
||||
col_w = max(14, *(len(f"n={n}") for n in sizes))
|
||||
header = " " * label_w + " " + " ".join(f"n={n}".rjust(col_w) for n in sizes)
|
||||
print("\n" + header)
|
||||
for label, cells in rows:
|
||||
print(label.ljust(label_w) + " " + " ".join(c.rjust(col_w) for c in cells))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--out", type=Path, default=Path("/tmp/livepass"))
|
||||
ap.add_argument("--serve", type=int, metavar="PORT")
|
||||
ap.add_argument("--perf", action="store_true", help="run the perf baseline and exit")
|
||||
ap.add_argument(
|
||||
"--perf-n",
|
||||
default="300,3000",
|
||||
help="comma-separated history sizes for --perf (default: 300,3000)",
|
||||
)
|
||||
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
|
||||
)
|
||||
if args.serve:
|
||||
import functools
|
||||
import http.server
|
||||
|
||||
class _FixtureHandler(http.server.SimpleHTTPRequestHandler):
|
||||
# The attachments harness loads thumbnails + the audio clip via
|
||||
# element .src; serve those from generated fixtures, fall through
|
||||
# to static for everything else.
|
||||
def do_GET(self) -> None: # noqa: N802 (stdlib casing)
|
||||
blob = _fixture_for(self.path.split("?")[0])
|
||||
if blob is None:
|
||||
super().do_GET()
|
||||
return
|
||||
data, ctype = blob
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
handler = functools.partial(_FixtureHandler, directory=str(args.out))
|
||||
handler = functools.partial(_HarnessHandler, directory=str(args.out))
|
||||
print(f"serving {args.out} on http://localhost:{args.serve}/ — Ctrl+C stops")
|
||||
http.server.ThreadingHTTPServer(("127.0.0.1", args.serve), handler).serve_forever()
|
||||
|
||||
|
||||
Generated
+102
-102
@@ -14,21 +14,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -37,9 +37,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -55,14 +55,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
"@tybys/wasm-util": "^0.10.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -74,9 +74,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"version": "0.138.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -84,9 +84,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -101,9 +101,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -118,9 +118,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -135,9 +135,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -152,9 +152,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
|
||||
"integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -169,9 +169,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -189,9 +189,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -209,9 +209,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -229,9 +229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -249,9 +249,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -269,9 +269,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -289,9 +289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -306,9 +306,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
|
||||
"integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -316,18 +316,18 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -342,9 +342,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -373,9 +373,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -559,9 +559,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
|
||||
"integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -576,9 +576,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@@ -962,9 +962,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -991,13 +991,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@oxc-project/types": "=0.138.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1007,21 +1007,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
"@rolldown/binding-android-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-x64": "1.1.4",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.4",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.4",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.4",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.4",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.4",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.4",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
@@ -1122,16 +1122,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz",
|
||||
"integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.3",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1148,7 +1148,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"@vitejs/devtools": "^0.3.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
||||
+111
-4
@@ -530,15 +530,17 @@ def test_phase8_appendtooloutput_dispatches_mcp_error_before_renderer() -> None:
|
||||
end = _pane_method_offset(body, "sendMessage")
|
||||
fn = body[start:end]
|
||||
parse_idx = fn.find("tryParseMcpError(")
|
||||
render_idx = fn.find("renderToolOutput(")
|
||||
# The plain-output render is the shared renderCollapsibleOutput helper; the
|
||||
# ordering invariant is unchanged — MCP dispatch must precede it.
|
||||
render_idx = fn.find("renderCollapsibleOutput(")
|
||||
assert parse_idx >= 0, (
|
||||
"appendToolOutput must call tryParseMcpError on the error path "
|
||||
"before renderToolOutput, otherwise the consent card never "
|
||||
"before the plain renderer, otherwise the consent card never "
|
||||
"replaces the plain JSON output."
|
||||
)
|
||||
assert render_idx >= 0, "renderToolOutput call must remain present"
|
||||
assert render_idx >= 0, "renderCollapsibleOutput call must remain present"
|
||||
assert parse_idx < render_idx, (
|
||||
"tryParseMcpError must run BEFORE renderToolOutput so the "
|
||||
"tryParseMcpError must run BEFORE the plain renderer so the "
|
||||
"interactive card path takes precedence over plain rendering."
|
||||
)
|
||||
|
||||
@@ -1587,6 +1589,89 @@ def test_early_paint_tool_pending_wiring() -> None:
|
||||
assert "if (!announced) this.messagesEl.appendChild(block);" in body
|
||||
|
||||
|
||||
def test_task_agent_steps_never_escape_their_card() -> None:
|
||||
"""A task agent's sub-tool steps (``parent_call_id`` stamped) must nest in
|
||||
the task card, never render as top-level rows that look like the main
|
||||
harness issued them. Two seams keep that true; this guards both against a
|
||||
rename/deletion:
|
||||
|
||||
1. ``tool_info`` routes through ``_routeAgentItems`` first — a sub-tool
|
||||
auto-resolved by policy / "Always" arrives as a ``tool_info`` and must
|
||||
nest, not paint a duplicate top-level block (Copilot review on #732).
|
||||
2. A child step whose ``task_agent`` row hasn't painted yet (the 4-wide
|
||||
tool pool's ordering window) is BUFFERED and flushed when the row lands,
|
||||
instead of escaping to top-level; the card also survives the parent
|
||||
row's pending->resolved rebuild.
|
||||
3. SAFETY VALVE: a buffered step whose parent row NEVER paints (an id-
|
||||
correlation mismatch / aborted agent) is escaped to a top-level row after
|
||||
a grace window, so it stays VISIBLE rather than buffered forever.
|
||||
"""
|
||||
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
|
||||
# 1. tool_info nests via the same router as tool_pending / approve_request.
|
||||
info = body[body.index('case "tool_info":') : body.index('case "approve_request":')]
|
||||
assert 'this._routeAgentItems(evt.items, "info")' in info, (
|
||||
"tool_info must route a parent-tagged sub-tool into the task card "
|
||||
"before any top-level showInlineToolBlock fallback."
|
||||
)
|
||||
# 2. _routeAgentItems buffers an orphan child (instead of returning false,
|
||||
# which escapes it to top-level) when the parent card isn't painted yet.
|
||||
route = body[
|
||||
_pane_method_offset(body, "_routeAgentItems") : _pane_method_offset(
|
||||
body, "_ensureAgentCard"
|
||||
)
|
||||
]
|
||||
assert "_bufferAgentOrphan(parentId, items, mode)" in route, (
|
||||
"a parent-tagged child with no card yet must buffer, not fall through to a top-level paint."
|
||||
)
|
||||
# The buffer / flush / escape / relink helpers exist.
|
||||
assert "_bufferAgentOrphan(parentId, items, mode) {" in body
|
||||
assert "_flushAgentOrphans(parentIds) {" in body
|
||||
assert "_escapeAgentOrphans(parentId) {" in body
|
||||
assert "_relinkAgentCards(items) {" in body
|
||||
assert body.count("this._relinkAgentCards(") >= 2, (
|
||||
"both announceToolBlock and showInlineToolBlock must relink + flush so "
|
||||
"a buffered step nests as soon as a tool row appears."
|
||||
)
|
||||
# 3. Safety valve: _bufferAgentOrphan arms a grace timer to _escapeAgentOrphans
|
||||
# so a never-painting parent's steps can't vanish (or leak) — they escape
|
||||
# back to a visible top-level paint.
|
||||
buf = body[
|
||||
_pane_method_offset(body, "_bufferAgentOrphan") : _pane_method_offset(
|
||||
body, "_flushAgentOrphans"
|
||||
)
|
||||
]
|
||||
assert "setTimeout(" in buf and "_escapeAgentOrphans(parentId)" in buf, (
|
||||
"a buffered orphan must arm a grace-window escape so it never stays "
|
||||
"buffered (invisible) forever."
|
||||
)
|
||||
escape = body[
|
||||
_pane_method_offset(body, "_escapeAgentOrphans") : _pane_method_offset(
|
||||
body, "_relinkAgentCards"
|
||||
)
|
||||
]
|
||||
assert "announceToolBlock(" in escape, (
|
||||
"the escape valve must render the steps top-level (visible), the "
|
||||
"pre-buffer behaviour, rather than dropping them."
|
||||
)
|
||||
# Flush is targeted to the just-painted parents, not the whole map.
|
||||
flush = body[
|
||||
_pane_method_offset(body, "_flushAgentOrphans") : _pane_method_offset(
|
||||
body, "_escapeAgentOrphans"
|
||||
)
|
||||
]
|
||||
assert "parentIds.forEach" in flush
|
||||
# _ensureAgentCard re-attaches a DETACHED card across a parent-row rebuild,
|
||||
# but builds fresh on a still-attached (cross-turn reused) call_id rather
|
||||
# than stealing the prior agent's steps.
|
||||
ensure = body[
|
||||
_pane_method_offset(body, "_ensureAgentCard") : _pane_method_offset(
|
||||
body, "_bufferAgentOrphan"
|
||||
)
|
||||
]
|
||||
assert "!card.wrap.isConnected" in ensure
|
||||
assert "parentRow.appendChild(card.wrap);" in ensure
|
||||
|
||||
|
||||
def test_risk_level_normalized_before_dom_interpolation() -> None:
|
||||
"""Server-supplied ``risk_level`` lands in className / data-risk strings the
|
||||
verdict + warning CSS depend on, so every interpolation must funnel through
|
||||
@@ -1650,3 +1735,25 @@ def test_early_paint_screen_reader_announce() -> None:
|
||||
assert "toolAnnounce(_toolAnnounceText(list))" in body
|
||||
assert 'block.setAttribute("aria-busy", "true")' in body
|
||||
assert 'block.removeAttribute("aria-busy")' in body
|
||||
|
||||
|
||||
def test_global_stream_recovery_floor_and_render_coalescing() -> None:
|
||||
"""Perf-audit P0/P1 for the Tier-1 global stream. The server's recovery
|
||||
events for a truncated reconnect gap (``node_snapshot`` as the floor,
|
||||
``replay_truncated`` as the marker) used to fall through the handler
|
||||
silently — workstreams created during a long hidden-tab gap never
|
||||
rendered again, and missed ``ws_closed`` left ghost rows forever. A
|
||||
malformed frame is the same permanent drift (the cursor advances before
|
||||
the parse), so it resyncs too. ``fireRender`` is rAF-coalesced: every
|
||||
``ws_state`` (≥2 per tool round per workstream) used to trigger a
|
||||
synchronous full rail rebuild."""
|
||||
body = _APP_JS.read_text(encoding="utf-8")
|
||||
assert 'data.type === "node_snapshot"' in body
|
||||
assert 'data.type === "replay_truncated"' in body
|
||||
assert "function applyRosterSnapshot(" in body
|
||||
assert "function resyncRoster(" in body
|
||||
assert "malformed frame" in body
|
||||
fire = body.index("function fireRender()")
|
||||
assert "requestAnimationFrame(" in body[fire : fire + 700], (
|
||||
"fireRender must coalesce subscriber repaints to one per frame"
|
||||
)
|
||||
|
||||
+37
-37
@@ -15,7 +15,14 @@ from turnstone.core.session import (
|
||||
_CancelRef,
|
||||
_effect_status_meta,
|
||||
)
|
||||
from turnstone.core.trajectory import EffectStatus, Role, dicts_from_turns, turn_from_dict
|
||||
from turnstone.core.trajectory import (
|
||||
EffectStatus,
|
||||
Role,
|
||||
ToolCall,
|
||||
Turn,
|
||||
dicts_from_turns,
|
||||
turn_from_dict,
|
||||
)
|
||||
|
||||
|
||||
class NullUI:
|
||||
@@ -1028,15 +1035,11 @@ class TestCancelledAgentDisposition:
|
||||
|
||||
@staticmethod
|
||||
def _assistant(call_id, name):
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": call_id, "function": {"name": name}}],
|
||||
}
|
||||
return Turn.assistant("", tool_calls=(ToolCall(id=call_id, name=name, arguments=""),))
|
||||
|
||||
@staticmethod
|
||||
def _result(call_id, text="ok"):
|
||||
return {"role": "tool", "tool_call_id": call_id, "content": text}
|
||||
return Turn.tool(call_id, text)
|
||||
|
||||
def test_status_none_when_no_actions(self):
|
||||
"""Typed twin of the disposition: a task cancelled before any action is
|
||||
@@ -1107,14 +1110,13 @@ class TestCancelledAgentDisposition:
|
||||
# started" — inviting a re-run of the destructive bash.
|
||||
session = _make_session()
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "t1", "function": {"name": "bash"}},
|
||||
{"id": "t2", "function": {"name": "web_fetch"}},
|
||||
],
|
||||
}
|
||||
Turn.assistant(
|
||||
"",
|
||||
tool_calls=(
|
||||
ToolCall(id="t1", name="bash", arguments=""),
|
||||
ToolCall(id="t2", name="web_fetch", arguments=""),
|
||||
),
|
||||
)
|
||||
] # neither answered: bash raised mid-flight, web_fetch never ran
|
||||
out = session._cancelled_agent_disposition(msgs, "task")
|
||||
assert "In flight at cancel: bash" in out
|
||||
@@ -1127,26 +1129,24 @@ class TestCancelledAgentDisposition:
|
||||
# count summary, the first-gap boundary, and not-started.
|
||||
session = _make_session()
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "t1", "function": {"name": "bash"}},
|
||||
{"id": "t2", "function": {"name": "bash"}},
|
||||
{"id": "t3", "function": {"name": "read_file"}},
|
||||
],
|
||||
},
|
||||
Turn.assistant(
|
||||
"",
|
||||
tool_calls=(
|
||||
ToolCall(id="t1", name="bash", arguments=""),
|
||||
ToolCall(id="t2", name="bash", arguments=""),
|
||||
ToolCall(id="t3", name="read_file", arguments=""),
|
||||
),
|
||||
),
|
||||
self._result("t1"),
|
||||
self._result("t2"),
|
||||
self._result("t3"),
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "t4", "function": {"name": "web_fetch"}},
|
||||
{"id": "t5", "function": {"name": "search"}},
|
||||
],
|
||||
},
|
||||
Turn.assistant(
|
||||
"",
|
||||
tool_calls=(
|
||||
ToolCall(id="t4", name="web_fetch", arguments=""),
|
||||
ToolCall(id="t5", name="search", arguments=""),
|
||||
),
|
||||
),
|
||||
]
|
||||
out = session._cancelled_agent_disposition(msgs, "task")
|
||||
assert "Completed before cancel: bash×2, read_file." in out
|
||||
@@ -1155,13 +1155,13 @@ class TestCancelledAgentDisposition:
|
||||
|
||||
def test_exec_task_routes_cancel_to_disposition(self, tmp_db):
|
||||
"""_exec_task converts a GenerationCancelled from _run_agent into the
|
||||
honest disposition, reading the in-place-mutated agent_messages."""
|
||||
honest disposition, reading the in-place-mutated agent_turns."""
|
||||
session = _make_session()
|
||||
|
||||
def fake_run_agent(agent_messages, **kwargs):
|
||||
agent_messages.append(self._assistant("t1", "bash"))
|
||||
agent_messages.append(self._result("t1"))
|
||||
agent_messages.append(self._assistant("t2", "web_fetch"))
|
||||
def fake_run_agent(agent_turns, **kwargs):
|
||||
agent_turns.append(self._assistant("t1", "bash"))
|
||||
agent_turns.append(self._result("t1"))
|
||||
agent_turns.append(self._assistant("t2", "web_fetch"))
|
||||
raise GenerationCancelled()
|
||||
|
||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""Tests for persisted compaction checkpoints (rehydration-deadlock fix).
|
||||
|
||||
Compaction swaps a session's in-memory history for a summary but leaves the full
|
||||
transcript in storage. Without a durable marker, ``resume()`` reloaded the full
|
||||
pre-compaction history, which on a long session — or one switched to a smaller-
|
||||
context model — exceeds the window and deadlocks the first send.
|
||||
|
||||
The fix persists one ``_source="compaction"`` marker (summary + watermark) so
|
||||
resume rehydrates ``[summary] + [rows after the watermark]`` while the full
|
||||
history stays in storage for ``/history``/export. Covered here:
|
||||
|
||||
- ``get_compaction_watermark`` — the boundary id (max-summarized), with and
|
||||
without a preserved tail, and on an empty workstream.
|
||||
- ``load_message_turns`` (resume) — checkpoint-aware slice, latest-marker-wins,
|
||||
preserved-tail handling, and the full-history fallbacks (no marker, malformed
|
||||
marker) that keep every pre-checkpoint session loading exactly as before.
|
||||
- ``load_messages`` (display) — markers stay invisible to ``/history``.
|
||||
- End-to-end: ``_compact_messages`` writes the marker and a fresh ``resume()``
|
||||
rehydrates the bounded view, not the full transcript.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._session_helpers import make_session
|
||||
from turnstone.core.trajectory import turns_from_dicts
|
||||
|
||||
|
||||
def _marker_meta(watermark: int | None) -> str | None:
|
||||
"""The marker's stored ``meta`` JSON (``None`` simulates a legacy/malformed marker)."""
|
||||
return json.dumps({"watermark": watermark}) if watermark is not None else None
|
||||
|
||||
|
||||
def _register(st, ws: str = "ws1") -> str:
|
||||
st.register_workstream(ws, user_id="u1", title="t", kind="interactive")
|
||||
return ws
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_compaction_watermark
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWatermark:
|
||||
def test_preserve_tail_zero_is_max_id(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
ids = [st.save_message(ws, "user", f"m{i}") for i in range(5)]
|
||||
assert st.get_compaction_watermark(ws, 0) == max(ids)
|
||||
|
||||
def test_preserve_tail_n_is_nth_newest(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
ids = sorted(st.save_message(ws, "user", f"m{i}") for i in range(5))
|
||||
# Keep the newest 2 verbatim → boundary is the 3rd-newest id.
|
||||
assert st.get_compaction_watermark(ws, 2) == ids[-3]
|
||||
|
||||
def test_preserve_tail_ignores_existing_markers(self, storage_backend):
|
||||
# A compaction marker is saved as a NEW row but is not part of the
|
||||
# preserved in-memory tail, so it must not shift the (preserve_tail+1)
|
||||
# boundary — without the exclusion, this returns ids[-1] (the marker
|
||||
# consumes an offset slot) and resume would drop a real tail row.
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
ids = [st.save_message(ws, "user", f"m{i}") for i in range(5)]
|
||||
st.save_message(ws, "assistant", "SUM", source="compaction", meta=_marker_meta(max(ids)))
|
||||
st.save_message(ws, "user", "m5")
|
||||
# Real rows newest-first: m5, m4, m3, ... → 3rd-newest real row is m3.
|
||||
assert st.get_compaction_watermark(ws, 2) == ids[-2]
|
||||
|
||||
def test_empty_workstream_is_none(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
assert st.get_compaction_watermark(ws, 0) is None
|
||||
|
||||
def test_preserve_tail_exceeding_row_count_is_none(self, storage_backend):
|
||||
# Fewer rows than the preserved tail → no boundary, so compaction skips
|
||||
# the marker rather than writing a watermark that points past the history.
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
st.save_message(ws, "user", "only")
|
||||
assert st.get_compaction_watermark(ws, 5) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_message_turns — checkpoint-aware resume
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckpointResume:
|
||||
def test_loads_summary_plus_tail_not_full_history(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
for i in range(5):
|
||||
st.save_message(ws, "user" if i % 2 == 0 else "assistant", f"old{i}")
|
||||
watermark = st.get_compaction_watermark(ws, 0)
|
||||
st.save_message(
|
||||
ws, "assistant", "THE SUMMARY", source="compaction", meta=_marker_meta(watermark)
|
||||
)
|
||||
st.save_message(ws, "user", "new question")
|
||||
st.save_message(ws, "assistant", "new answer")
|
||||
|
||||
texts = [t.text for t in st.load_message_turns(ws)]
|
||||
assert texts == ["[Conversation summary]", "THE SUMMARY", "new question", "new answer"]
|
||||
assert not any("old" in x for x in texts) # summarized prefix is gone
|
||||
|
||||
def test_preserved_tail_kept_after_summary(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
ids = sorted(st.save_message(ws, "user", f"m{i}") for i in range(4))
|
||||
# Mid-turn compaction keeps the newest row (m3) verbatim.
|
||||
watermark = st.get_compaction_watermark(ws, 1)
|
||||
assert watermark == ids[-2]
|
||||
st.save_message(ws, "assistant", "SUM", source="compaction", meta=_marker_meta(watermark))
|
||||
|
||||
texts = [t.text for t in st.load_message_turns(ws)]
|
||||
assert texts == ["[Conversation summary]", "SUM", "m3"]
|
||||
|
||||
def test_latest_marker_wins(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
st.save_message(ws, "user", "old")
|
||||
st.save_message(
|
||||
ws,
|
||||
"assistant",
|
||||
"SUMMARY 1",
|
||||
source="compaction",
|
||||
meta=_marker_meta(st.get_compaction_watermark(ws, 0)),
|
||||
)
|
||||
st.save_message(ws, "user", "mid")
|
||||
st.save_message(
|
||||
ws,
|
||||
"assistant",
|
||||
"SUMMARY 2",
|
||||
source="compaction",
|
||||
meta=_marker_meta(st.get_compaction_watermark(ws, 0)),
|
||||
)
|
||||
st.save_message(ws, "user", "after")
|
||||
|
||||
texts = [t.text for t in st.load_message_turns(ws)]
|
||||
assert texts == ["[Conversation summary]", "SUMMARY 2", "after"]
|
||||
assert "SUMMARY 1" not in texts and "old" not in texts and "mid" not in texts
|
||||
|
||||
def test_no_marker_loads_full_history(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
for i in range(3):
|
||||
st.save_message(ws, "user", f"m{i}")
|
||||
assert [t.text for t in st.load_message_turns(ws)] == ["m0", "m1", "m2"]
|
||||
|
||||
def test_malformed_marker_falls_back_to_full_history(self, storage_backend):
|
||||
# A marker with no watermark (legacy/corrupt) must NOT slice — losing
|
||||
# real messages is worse than reloading more than necessary.
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
st.save_message(ws, "user", "a")
|
||||
st.save_message(ws, "assistant", "SUMMARY", source="compaction", meta=None)
|
||||
st.save_message(ws, "user", "b")
|
||||
texts = [t.text for t in st.load_message_turns(ws)]
|
||||
assert "a" in texts and "b" in texts # no real message dropped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_messages — display path keeps markers invisible
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDisplayPath:
|
||||
def test_history_excludes_marker(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
st.save_message(ws, "user", "q")
|
||||
st.save_message(ws, "assistant", "a")
|
||||
st.save_message(
|
||||
ws,
|
||||
"assistant",
|
||||
"SUMMARY",
|
||||
source="compaction",
|
||||
meta=_marker_meta(st.get_compaction_watermark(ws, 0)),
|
||||
)
|
||||
|
||||
contents = [m.get("content") for m in st.load_messages(ws)]
|
||||
assert "SUMMARY" not in contents
|
||||
assert contents == ["q", "a"] # true transcript, no injected summary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: compaction writes the marker, resume is bounded
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_compaction_persists_checkpoint_and_resume_is_bounded(tmp_db, mock_openai_client):
|
||||
"""The deadlock-fix proof: a session compacts, a fresh session reopens it,
|
||||
and resume rehydrates [summary]+[tail] — never the full pre-compaction
|
||||
transcript that would overflow the window on reopen."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.memory import register_workstream, save_message
|
||||
|
||||
ws = "wsE2E"
|
||||
register_workstream(ws, user_id="u1", name="t")
|
||||
history = [
|
||||
{"role": "user" if i % 2 == 0 else "assistant", "content": f"turn {i}"} for i in range(6)
|
||||
]
|
||||
for h in history:
|
||||
save_message(ws, h["role"], h["content"])
|
||||
|
||||
sess = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
||||
sess._ws_id = ws
|
||||
sess.messages = turns_from_dicts(history)
|
||||
sess._msg_tokens = [1] * len(history)
|
||||
with patch.object(sess, "_summarize_blocks", return_value="DENSE SUMMARY"):
|
||||
assert sess._compact_messages(auto=False) is True
|
||||
|
||||
# Conversation continues after the compaction.
|
||||
save_message(ws, "user", "after compaction")
|
||||
|
||||
# A fresh session reopens the workstream.
|
||||
sess2 = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
||||
assert sess2.resume(ws) is True
|
||||
texts = [t.text for t in sess2.messages]
|
||||
|
||||
assert texts[:2] == ["[Conversation summary]", "DENSE SUMMARY"]
|
||||
assert "after compaction" in texts
|
||||
assert not any(t.startswith("turn ") for t in texts) # full history NOT reloaded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Malformed / edge-case markers — the watermark guards and the empty tail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMarkerEdges:
|
||||
@pytest.mark.parametrize(
|
||||
"meta",
|
||||
[
|
||||
json.dumps({"watermark": "5"}), # non-int (string)
|
||||
json.dumps({"watermark": True}), # bool — True is an int subclass
|
||||
json.dumps({}), # key absent
|
||||
json.dumps({"watermark": None}), # null
|
||||
],
|
||||
)
|
||||
def test_non_int_watermark_falls_back_to_full_history(self, storage_backend, meta):
|
||||
# A watermark that isn't a real int must NOT slice (a True watermark
|
||||
# would otherwise cut at id 1 and drop real history).
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
st.save_message(ws, "user", "a")
|
||||
st.save_message(ws, "assistant", "b")
|
||||
st.save_message(ws, "assistant", "SUMMARY", source="compaction", meta=meta)
|
||||
st.save_message(ws, "user", "c")
|
||||
texts = [t.text for t in st.load_message_turns(ws)]
|
||||
assert "a" in texts and "b" in texts and "c" in texts # nothing sliced away
|
||||
# ...and the malformed marker is DROPPED, not leaked as a stray summary turn.
|
||||
assert "SUMMARY" not in texts
|
||||
|
||||
def test_marker_as_final_row_yields_empty_tail(self, storage_backend):
|
||||
# watermark == max id, marker is the last row → resume is just the summary.
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
for i in range(3):
|
||||
st.save_message(ws, "user", f"old{i}")
|
||||
wm = st.get_compaction_watermark(ws, 0)
|
||||
st.save_message(ws, "assistant", "SUMMARY", source="compaction", meta=_marker_meta(wm))
|
||||
assert [t.text for t in st.load_message_turns(ws)] == ["[Conversation summary]", "SUMMARY"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# checkpointed=False — export/audit gets the FULL transcript (markers dropped)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFullHistoryLoad:
|
||||
def test_checkpointed_false_returns_full_history_without_marker(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
for i in range(4):
|
||||
st.save_message(ws, "user" if i % 2 == 0 else "assistant", f"old{i}")
|
||||
wm = st.get_compaction_watermark(ws, 0)
|
||||
st.save_message(ws, "assistant", "SUMMARY", source="compaction", meta=_marker_meta(wm))
|
||||
st.save_message(ws, "user", "after")
|
||||
|
||||
# Resume (default) is bounded; export (checkpointed=False) is full + marker-free.
|
||||
assert [t.text for t in st.load_message_turns(ws)] == [
|
||||
"[Conversation summary]",
|
||||
"SUMMARY",
|
||||
"after",
|
||||
]
|
||||
full = [t.text for t in st.load_message_turns(ws, checkpointed=False)]
|
||||
assert full == ["old0", "old1", "old2", "old3", "after"]
|
||||
assert "SUMMARY" not in full and "[Conversation summary]" not in full
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search — compaction markers stay out of search results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSearchExclusion:
|
||||
def test_search_history_excludes_markers(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
st.save_message(ws, "user", "findme apple")
|
||||
st.save_message(
|
||||
ws,
|
||||
"assistant",
|
||||
"findme SUMMARY banana",
|
||||
source="compaction",
|
||||
meta=_marker_meta(st.get_compaction_watermark(ws, 0)),
|
||||
)
|
||||
contents = [r[3] for r in st.search_history("findme")]
|
||||
assert any("apple" in (c or "") for c in contents) # real row matched
|
||||
assert not any("SUMMARY" in (c or "") for c in contents) # marker excluded
|
||||
# ...and normal rows (whose _source is NULL) are NOT dropped by the filter.
|
||||
assert contents
|
||||
|
||||
def test_search_history_recent_excludes_markers(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
st.save_message(ws, "user", "real")
|
||||
st.save_message(
|
||||
ws,
|
||||
"assistant",
|
||||
"SUMMARY",
|
||||
source="compaction",
|
||||
meta=_marker_meta(st.get_compaction_watermark(ws, 0)),
|
||||
)
|
||||
recent = [r[3] for r in st.search_history_recent(10)]
|
||||
assert "real" in recent and "SUMMARY" not in recent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rewind / retry — compaction-safe truncation (never delete the summary backing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompactionFloor:
|
||||
def test_floor_and_count(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
for i in range(3):
|
||||
st.save_message(ws, "user", f"old{i}") # summarized prefix
|
||||
wm = st.get_compaction_watermark(ws, 0)
|
||||
st.save_message(ws, "assistant", "SUMMARY", source="compaction", meta=_marker_meta(wm))
|
||||
st.save_message(ws, "user", "tail1")
|
||||
st.save_message(ws, "assistant", "tail2")
|
||||
assert st.get_compaction_floor(ws) == 4 # 3 prefix + 1 marker
|
||||
assert st.count_messages(ws) == 6
|
||||
|
||||
def test_floor_zero_without_marker(self, storage_backend):
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
st.save_message(ws, "user", "x")
|
||||
assert st.get_compaction_floor(ws) == 0
|
||||
|
||||
|
||||
def test_rewind_after_compaction_never_deletes_summary_backing(tmp_db, mock_openai_client):
|
||||
"""The review's major rewind finding: after a compaction, a tail-trim must
|
||||
delete from the storage TAIL and floor at the marker, not keep the oldest
|
||||
summarized rows and drop the marker."""
|
||||
from turnstone.core.memory import get_storage, register_workstream, save_message
|
||||
|
||||
ws = "wsRW"
|
||||
register_workstream(ws, user_id="u1", name="t")
|
||||
for i in range(3):
|
||||
save_message(ws, "user", f"old{i}") # prefix
|
||||
st = get_storage()
|
||||
wm = st.get_compaction_watermark(ws, 0)
|
||||
save_message(
|
||||
ws, "assistant", "SUMMARY", source="compaction", meta=json.dumps({"watermark": wm})
|
||||
)
|
||||
save_message(ws, "user", "q1") # tail
|
||||
save_message(ws, "assistant", "a1") # tail
|
||||
assert st.get_compaction_floor(ws) == 4 and st.count_messages(ws) == 6
|
||||
|
||||
sess = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
||||
sess._ws_id = ws
|
||||
|
||||
# Trim one tail turn → keep = max(floor 4, total 6 - 1) = 5 → deletes only "a1".
|
||||
sess._persist_truncation(1)
|
||||
assert st.count_messages(ws) == 5
|
||||
survived = [t.text for t in st.load_message_turns(ws)]
|
||||
assert survived[:2] == ["[Conversation summary]", "SUMMARY"] # marker + prefix intact
|
||||
assert "q1" in survived
|
||||
|
||||
# Over-deep trim → clamps at the floor; the marker + prefix still survive.
|
||||
sess._persist_truncation(100)
|
||||
assert st.count_messages(ws) == 4 # floored at prefix + marker
|
||||
after = [t.text for t in st.load_message_turns(ws)]
|
||||
assert after == ["[Conversation summary]", "SUMMARY"] # summary backing never deleted
|
||||
|
||||
|
||||
def test_persist_truncation_uncompacted_matches_plain_tail_delete(tmp_db, mock_openai_client):
|
||||
"""With no compaction (floor 0), the new path is identical to the old
|
||||
keep=len(self.messages) tail delete."""
|
||||
from turnstone.core.memory import get_storage, register_workstream, save_message
|
||||
|
||||
ws = "wsPlain"
|
||||
register_workstream(ws, user_id="u1", name="t")
|
||||
for i in range(5):
|
||||
save_message(ws, "user", f"m{i}")
|
||||
st = get_storage()
|
||||
assert st.get_compaction_floor(ws) == 0
|
||||
|
||||
sess = make_session(client=mock_openai_client, context_window=10_000, max_tokens=1_000)
|
||||
sess._ws_id = ws
|
||||
sess._persist_truncation(2) # remove the last 2
|
||||
assert st.count_messages(ws) == 3
|
||||
|
||||
|
||||
def test_persist_truncation_skips_delete_when_count_unavailable(tmp_db, mock_openai_client):
|
||||
"""count_messages==0 (the storage-error sentinel) must NOT delete — a wrong
|
||||
truncation would lose user history."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.memory import get_storage, register_workstream, save_message
|
||||
|
||||
ws = "wsCnt"
|
||||
register_workstream(ws, user_id="u1", name="t")
|
||||
for i in range(4):
|
||||
save_message(ws, "user", f"m{i}")
|
||||
st = get_storage()
|
||||
sess = make_session(client=mock_openai_client)
|
||||
sess._ws_id = ws
|
||||
with patch("turnstone.core.session.count_messages", return_value=0):
|
||||
sess._persist_truncation(2)
|
||||
assert st.count_messages(ws) == 4 # nothing deleted
|
||||
|
||||
|
||||
def test_persist_truncation_skips_delete_when_floor_unavailable(tmp_db, mock_openai_client):
|
||||
"""get_compaction_floor==-1 (the storage-error sentinel) must NOT delete — a 0
|
||||
floor on a compacted ws could otherwise drop the marker on an over-deep trim."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.memory import get_storage, register_workstream, save_message
|
||||
|
||||
ws = "wsFloor"
|
||||
register_workstream(ws, user_id="u1", name="t")
|
||||
for i in range(4):
|
||||
save_message(ws, "user", f"m{i}")
|
||||
st = get_storage()
|
||||
sess = make_session(client=mock_openai_client)
|
||||
sess._ws_id = ws
|
||||
with patch("turnstone.core.session.get_compaction_floor", return_value=-1):
|
||||
sess._persist_truncation(2)
|
||||
assert st.count_messages(ws) == 4 # nothing deleted
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Tests for the compaction crossing discipline: what crosses the summary
|
||||
boundary VERBATIM (not only as summarizer paraphrase) and how the synthetic
|
||||
summary turns are recognized.
|
||||
|
||||
- **Provenance tags** — ``_compact_messages`` and
|
||||
``reconstruct_turns_checkpointed`` mark both synthetic summary turns
|
||||
``source="compaction"``; ``_find_turn_boundaries`` and ``_generate_title``
|
||||
test the tag, not the ``[Conversation summary]`` content string. A user
|
||||
who literally types the label therefore stays a REAL turn (previously it
|
||||
was silently treated as synthetic — provenance by spelling).
|
||||
- **Carry budget** — ``_carry_budget_chars`` scales the verbatim-carry
|
||||
allowance to ~25% of the window (clamped by the summary output reserve,
|
||||
floored at ``_MIN_CARRY_BUDGET_CHARS``), replacing the fixed 400-char
|
||||
continuation-hint clip; oversize content keeps head + tail around an
|
||||
honest marker.
|
||||
- **Wind-down spill** — with ``carry_spill=True`` (the end-of-turn site
|
||||
passes the ``stopped_to_compact`` latch) the final summarized assistant
|
||||
turn's text is copied onto the summary under ``## Wind-down (verbatim)``
|
||||
— shell concatenation, so the model's own plan statement survives the
|
||||
collapse even when the summarizer paraphrases it.
|
||||
- The overflow-backstop compact-and-retry passes ``my_generation`` so a
|
||||
stale send cannot compact-and-swap a newer generation's history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._session_helpers import make_session
|
||||
from turnstone.core.session import COMPACTION_SOURCE, COMPACTION_SUMMARY_LABEL
|
||||
from turnstone.core.trajectory import turns_from_dicts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(tmp_db, mock_openai_client):
|
||||
"""Small-window session: context_window=10_000, compact_max_tokens=100 so
|
||||
the summary output reserve is tiny and the carry budget is easy to compute
|
||||
(reserve=100, margin=500, spare=9_400, budget=min(2_500, 9_400)=2_500
|
||||
tokens → 10_000 chars at the uncalibrated 4.0 chars/token)."""
|
||||
return make_session(
|
||||
client=mock_openai_client,
|
||||
context_window=10_000,
|
||||
compact_max_tokens=100,
|
||||
max_tokens=1_000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def _stub_summary(text: str = "DENSE"):
|
||||
return SimpleNamespace(content=text, finish_reason="stop")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provenance tags on the synthetic summary turns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSummaryTurnProvenance:
|
||||
def test_compact_tags_both_summary_turns(self, session):
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "do the thing"},
|
||||
{"role": "assistant", "content": "did the thing"},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [1, 1]
|
||||
with patch.object(session, "_utility_completion", return_value=_stub_summary()):
|
||||
assert session._compact_messages(auto=True) is True
|
||||
|
||||
label, summary = session.messages[0], session.messages[1]
|
||||
assert label.text == COMPACTION_SUMMARY_LABEL
|
||||
assert label.source == COMPACTION_SOURCE
|
||||
assert summary.source == COMPACTION_SOURCE
|
||||
|
||||
def test_boundaries_exclude_tagged_label_only(self, session):
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": COMPACTION_SUMMARY_LABEL,
|
||||
"_source": COMPACTION_SOURCE,
|
||||
},
|
||||
{"role": "assistant", "content": "summary"},
|
||||
{"role": "user", "content": "real follow-up"},
|
||||
]
|
||||
)
|
||||
assert session._find_turn_boundaries() == [2]
|
||||
|
||||
def test_literal_label_from_user_is_a_real_boundary(self, session):
|
||||
"""A user who literally types '[Conversation summary]' is not a
|
||||
compaction artifact — provenance rides the tag, not the spelling."""
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": COMPACTION_SUMMARY_LABEL}])
|
||||
assert session._find_turn_boundaries() == [0]
|
||||
|
||||
def test_title_gen_titles_from_literal_label_user(self, session):
|
||||
"""The tag distinction reaches _generate_title: a synthetic label is
|
||||
skipped (pinned in test_cooperative_compaction), but a REAL user
|
||||
message that happens to equal the label is titled from normally."""
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": COMPACTION_SUMMARY_LABEL},
|
||||
{"role": "assistant", "content": "an answer"},
|
||||
]
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
session, "_utility_completion", return_value=_stub_summary("A Title")
|
||||
) as uc,
|
||||
patch.object(session, "ui", new=MagicMock()),
|
||||
):
|
||||
session._generate_title()
|
||||
|
||||
uc.assert_called_once()
|
||||
prompt = uc.call_args[0][0][-1]["content"]
|
||||
assert COMPACTION_SUMMARY_LABEL in prompt # titled FROM the real message
|
||||
|
||||
|
||||
class TestCheckpointReconstructionProvenance:
|
||||
def test_resume_turns_carry_compaction_source(self, storage_backend):
|
||||
"""A reopened session must see the same provenance the live session
|
||||
held: reconstruct_turns_checkpointed tags the synthetic label AND the
|
||||
marker-backed summary turn, while real tail rows stay untagged."""
|
||||
st = storage_backend
|
||||
st.register_workstream("ws1", user_id="u1", title="t", kind="interactive")
|
||||
st.save_message("ws1", "user", "old question")
|
||||
st.save_message("ws1", "assistant", "old answer")
|
||||
watermark = st.get_compaction_watermark("ws1", 0)
|
||||
st.save_message(
|
||||
"ws1",
|
||||
"assistant",
|
||||
"THE SUMMARY",
|
||||
source=COMPACTION_SOURCE,
|
||||
meta=json.dumps({"watermark": watermark}),
|
||||
)
|
||||
st.save_message("ws1", "user", "new question")
|
||||
|
||||
turns = st.load_message_turns("ws1")
|
||||
assert [t.text for t in turns] == [
|
||||
COMPACTION_SUMMARY_LABEL,
|
||||
"THE SUMMARY",
|
||||
"new question",
|
||||
]
|
||||
assert turns[0].source == COMPACTION_SOURCE
|
||||
assert turns[1].source == COMPACTION_SOURCE
|
||||
assert turns[2].source is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Carry budget — the verbatim-crossing allowance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _isolate_overhead(s, system_tokens: int = 0) -> None:
|
||||
"""Pin the fixed prompt overhead (system + tool defs) for exact budget
|
||||
arithmetic — the real values vary with the composed prompt and registered
|
||||
tools (same isolation pattern as TestRemainingTokenBudget)."""
|
||||
s._system_tokens = system_tokens
|
||||
s._tools = []
|
||||
|
||||
|
||||
class TestCarryBudget:
|
||||
def test_scales_to_quarter_window(self, session):
|
||||
# overhead=0, reserve=100 (compact_max_tokens), margin=500,
|
||||
# spare=9_400; min(10_000 // 4, 9_400) = 2_500 tokens * 4.0 chars/token.
|
||||
_isolate_overhead(session)
|
||||
assert session._carry_budget_chars() == 10_000
|
||||
|
||||
def test_floors_on_tiny_window(self, tmp_db, mock_openai_client):
|
||||
tiny = make_session(client=mock_openai_client, context_window=1_000, tool_timeout=10)
|
||||
_isolate_overhead(tiny)
|
||||
assert tiny._carry_budget_chars() == tiny._MIN_CARRY_BUDGET_CHARS
|
||||
|
||||
@pytest.mark.parametrize("carries", [1, 2])
|
||||
def test_overhead_reserve_and_carries_fit_window_at_shipped_defaults(
|
||||
self, tmp_db, mock_openai_client, carries
|
||||
):
|
||||
"""The invariant that prevents a carry-induced overflow, pinned at the
|
||||
SHIPPED defaults (budget bugs hide behind test-sized configs), for
|
||||
BOTH carry counts, and INCLUDING the fixed prompt overhead: the
|
||||
post-compaction prompt is system + tools + summary + carries, so a
|
||||
budget that ignores the overhead (or sizes carries independently)
|
||||
stacks past the window and the backstop re-compacts the carries
|
||||
away."""
|
||||
s = make_session(client=mock_openai_client, tool_timeout=10)
|
||||
_isolate_overhead(s, system_tokens=4_000) # a chunky composed prompt
|
||||
reserve = s._summary_output_tokens()
|
||||
per_carry_tokens = s._carry_budget_chars(carries) / s._chars_per_token
|
||||
margin = int(s.context_window * s._SUMMARY_SAFETY_MARGIN)
|
||||
assert 4_000 + reserve + carries * per_carry_tokens + margin <= s.context_window
|
||||
|
||||
def test_budget_shrinks_with_prompt_overhead(self, tmp_db, mock_openai_client):
|
||||
"""Monotonicity pin: the overhead term is genuinely in the formula —
|
||||
a bigger system prompt leaves less to carry."""
|
||||
s = make_session(client=mock_openai_client, tool_timeout=10)
|
||||
_isolate_overhead(s, system_tokens=0)
|
||||
roomy = s._carry_budget_chars(2)
|
||||
_isolate_overhead(s, system_tokens=8_000)
|
||||
assert s._carry_budget_chars(2) < roomy
|
||||
|
||||
def test_double_carry_splits_the_spare(self, tmp_db, mock_openai_client):
|
||||
"""At shipped defaults the spare (window − overhead − reserve −
|
||||
margin) binds two carries: each gets spare // 2, strictly less than
|
||||
the solo quarter-window allowance."""
|
||||
s = make_session(client=mock_openai_client, tool_timeout=10)
|
||||
_isolate_overhead(s, system_tokens=2_000)
|
||||
reserve = s._summary_output_tokens()
|
||||
margin = int(s.context_window * s._SUMMARY_SAFETY_MARGIN)
|
||||
spare = s.context_window - reserve - margin - 2_000
|
||||
assert s._carry_budget_chars(2) == int((spare // 2) * s._chars_per_token)
|
||||
assert s._carry_budget_chars(2) < s._carry_budget_chars(1)
|
||||
|
||||
|
||||
class TestContinuationHintCarry:
|
||||
def test_long_ask_crosses_verbatim(self, session):
|
||||
"""A 3_000-char user message is within the 10_000-char carry budget and
|
||||
must cross whole — the old fixed clip kept 400 chars of it."""
|
||||
ask = "spec line\n" * 300 # 3_000 chars
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": ask},
|
||||
{"role": "assistant", "content": "working on it"},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [1, 1]
|
||||
with patch.object(session, "_utility_completion", return_value=_stub_summary()):
|
||||
assert session._compact_messages(auto=True) is True
|
||||
|
||||
summary_text = session.messages[1].text or ""
|
||||
assert ask.strip() in summary_text # verbatim, not clipped
|
||||
assert "## Continue" in summary_text
|
||||
|
||||
def test_oversize_ask_keeps_head_and_tail_with_marker(self, session):
|
||||
head_sentinel = "HEAD-OF-SPEC"
|
||||
tail_sentinel = "TAIL-OF-SPEC"
|
||||
ask = head_sentinel + ("x" * 20_000) + tail_sentinel # over the 10_000 budget
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": ask},
|
||||
{"role": "assistant", "content": "working on it"},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [1, 1]
|
||||
with patch.object(session, "_utility_completion", return_value=_stub_summary()):
|
||||
assert session._compact_messages(auto=True) is True
|
||||
|
||||
summary_text = session.messages[1].text or ""
|
||||
assert head_sentinel in summary_text
|
||||
assert tail_sentinel in summary_text
|
||||
# The marker reports the ORIGINAL size, and the summary tells the
|
||||
# model the full text is retrievable — a truncated carry is a cache
|
||||
# miss with a pointer, not a silent loss.
|
||||
assert f"…[truncated — {len(ask):,} chars total]…" in summary_text
|
||||
assert "the recall tool can retrieve it" in summary_text
|
||||
assert ask not in summary_text # genuinely truncated
|
||||
|
||||
def test_untruncated_carry_gets_no_recall_pointer(self, session):
|
||||
"""The retrievability note appears ONLY when something was cut."""
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "short ask"},
|
||||
{"role": "assistant", "content": "working on it"},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [1, 1]
|
||||
with patch.object(session, "_utility_completion", return_value=_stub_summary()):
|
||||
assert session._compact_messages(auto=True) is True
|
||||
assert "recall tool" not in (session.messages[1].text or "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wind-down spill — the model's plan statement crosses verbatim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWindDownSpill:
|
||||
SPILL = (
|
||||
"Goal: finish the migration.\n"
|
||||
"Remaining: backfill rows 300-900, rerun the verifier.\n"
|
||||
"Next step: resume at scripts/backfill.py --from 300."
|
||||
)
|
||||
|
||||
def _compacted_summary(self, session, *, carry_spill: bool) -> str:
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "please migrate the database"},
|
||||
{"role": "assistant", "content": self.SPILL},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [1, 1]
|
||||
with patch.object(session, "_utility_completion", return_value=_stub_summary()):
|
||||
assert session._compact_messages(auto=True, carry_spill=carry_spill) is True
|
||||
return session.messages[1].text or ""
|
||||
|
||||
def test_spill_copied_verbatim_under_heading(self, session):
|
||||
summary_text = self._compacted_summary(session, carry_spill=True)
|
||||
assert "## Wind-down (verbatim)" in summary_text
|
||||
assert self.SPILL in summary_text # copied, not paraphrased
|
||||
# Ordering: recorded plan first, then how to resume.
|
||||
assert summary_text.index("## Wind-down (verbatim)") < summary_text.index("## Continue")
|
||||
|
||||
def test_no_spill_without_flag(self, session):
|
||||
summary_text = self._compacted_summary(session, carry_spill=False)
|
||||
assert "## Wind-down (verbatim)" not in summary_text
|
||||
|
||||
def test_no_spill_when_last_summarized_turn_is_not_assistant(self, session):
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "assistant", "content": "answer"},
|
||||
{"role": "user", "content": "next task"},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [1, 1]
|
||||
with patch.object(session, "_utility_completion", return_value=_stub_summary()):
|
||||
assert session._compact_messages(auto=True, carry_spill=True) is True
|
||||
assert "## Wind-down (verbatim)" not in (session.messages[1].text or "")
|
||||
|
||||
def test_empty_spill_adds_no_heading(self, session):
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "task"},
|
||||
{"role": "assistant", "content": " "},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [1, 1]
|
||||
with patch.object(session, "_utility_completion", return_value=_stub_summary()):
|
||||
assert session._compact_messages(auto=True, carry_spill=True) is True
|
||||
assert "## Wind-down (verbatim)" not in (session.messages[1].text or "")
|
||||
|
||||
def test_oversize_spill_truncated_by_carry_budget(self, session):
|
||||
big_spill = "PLAN-HEAD " + ("y" * 20_000) + " PLAN-TAIL"
|
||||
session.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "task"},
|
||||
{"role": "assistant", "content": big_spill},
|
||||
]
|
||||
)
|
||||
session._msg_tokens = [1, 1]
|
||||
with patch.object(session, "_utility_completion", return_value=_stub_summary()):
|
||||
assert session._compact_messages(auto=True, carry_spill=True) is True
|
||||
summary_text = session.messages[1].text or ""
|
||||
assert "PLAN-HEAD" in summary_text and "PLAN-TAIL" in summary_text
|
||||
assert "…[truncated —" in summary_text
|
||||
assert "the recall tool can retrieve it" in summary_text
|
||||
|
||||
def test_double_carry_shares_the_budget(self, tmp_db, mock_openai_client):
|
||||
"""Spill + hint on ONE compaction — the end-of-turn shape — must fit
|
||||
the window together. At the shipped window defaults each carry gets
|
||||
spare // 2, so two oversize carries land truncated to the shared
|
||||
budget instead of stacking two solo quarter-window allowances on top
|
||||
of the half-window summary reserve."""
|
||||
s = make_session(client=mock_openai_client, tool_timeout=10)
|
||||
per_carry = s._carry_budget_chars(2)
|
||||
ask = "ASK-HEAD " + "a" * (per_carry * 2) + " ASK-TAIL"
|
||||
spill = "PLAN-HEAD " + "b" * (per_carry * 2) + " PLAN-TAIL"
|
||||
s.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": ask},
|
||||
{"role": "assistant", "content": spill},
|
||||
]
|
||||
)
|
||||
s._msg_tokens = [1, 1]
|
||||
with patch.object(s, "_utility_completion", return_value=_stub_summary()):
|
||||
assert s._compact_messages(auto=True, carry_spill=True) is True
|
||||
|
||||
text = s.messages[1].text or ""
|
||||
assert "## Wind-down (verbatim)" in text and "## Continue" in text
|
||||
for sentinel in ("ASK-HEAD", "ASK-TAIL", "PLAN-HEAD", "PLAN-TAIL"):
|
||||
assert sentinel in text
|
||||
assert text.count("…[truncated —") == 2 # both carries hit the shared cap
|
||||
framing = 700 # headings, hint wording, stub summary, recall pointer
|
||||
assert len(text) <= 2 * per_carry + framing
|
||||
|
||||
def test_do_auto_compact_forwards_carry_spill(self, session):
|
||||
"""The end-of-turn site passes carry_spill=stopped_to_compact through
|
||||
_do_auto_compact — pin the forwarding."""
|
||||
with patch.object(session, "_compact_messages", return_value=True) as cm:
|
||||
session._do_auto_compact(my_generation=3, carry_spill=True)
|
||||
assert cm.call_args.kwargs["carry_spill"] is True
|
||||
assert cm.call_args.kwargs["my_generation"] == 3
|
||||
+55
-1
@@ -4,7 +4,7 @@ import asyncio
|
||||
import json
|
||||
import queue
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import ANY, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -531,6 +531,58 @@ class TestCollectorDelta:
|
||||
assert event["type"] == "ws_closed"
|
||||
assert "ws1" not in c._nodes["node-a"].workstreams
|
||||
|
||||
def test_reconcile_additions_event_carries_tenancy_fields(self):
|
||||
"""The poll-diff ws_created must carry user_id + project_id — the
|
||||
console's per-connection tenancy filter gates on them, and a
|
||||
missing field fails open (private leak) or over-hides (creator
|
||||
shortcut can't fire)."""
|
||||
c = _make_collector()
|
||||
node = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
c._nodes["node-a"] = node
|
||||
|
||||
pending = c._reconcile_node(
|
||||
"node-a",
|
||||
node,
|
||||
[
|
||||
{
|
||||
"id": "ws1",
|
||||
"name": "n",
|
||||
"state": "idle",
|
||||
"kind": "interactive",
|
||||
"user_id": "alice",
|
||||
"project_id": "p1",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
created = [e for e in pending if e["type"] == "ws_created"]
|
||||
assert len(created) == 1
|
||||
assert created[0]["user_id"] == "alice"
|
||||
assert created[0]["project_id"] == "p1"
|
||||
|
||||
def test_emit_console_ws_created_carries_project(self):
|
||||
"""Console pseudo-node coordinator rows + their ws_created must
|
||||
carry project_id or private-project coordinators leak on the
|
||||
SSE surface (the REST lane filters via _coordinator_rows)."""
|
||||
c = _make_collector()
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c.emit_console_ws_created(
|
||||
"cws1",
|
||||
name="C",
|
||||
user_id="alice",
|
||||
kind="coordinator",
|
||||
project_id="p1",
|
||||
)
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_created"
|
||||
assert event["user_id"] == "alice"
|
||||
assert event["project_id"] == "p1"
|
||||
row = c._nodes[c.CONSOLE_PSEUDO_NODE_ID].workstreams["cws1"]
|
||||
assert row["project_id"] == "p1"
|
||||
|
||||
def test_apply_delta_ws_rename(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
@@ -1046,6 +1098,8 @@ class TestConsoleHTTPEndpoints:
|
||||
page=1,
|
||||
per_page=25,
|
||||
extra_rows=[],
|
||||
# Per-request private-project tenancy closure — identity varies.
|
||||
row_filter=ANY,
|
||||
)
|
||||
|
||||
def test_get_workstreams_per_page_capped(self, client, mock_collector):
|
||||
|
||||
@@ -150,3 +150,21 @@ def test_warning_and_verdict_normalize_risk() -> None:
|
||||
assert "normalizeRiskLevel(a.risk_level)" in body, "warning must normalize"
|
||||
assert '"conv-warning conv-warning--" + risk' in body
|
||||
assert 'badge.classList.add("conv-verdict--" + risk)' in body
|
||||
|
||||
|
||||
def test_unbounded_render_inputs_are_capped() -> None:
|
||||
"""Perf-audit P0: the two builders that used to render unbounded input.
|
||||
The diff preview caps rendered lines and appends incrementally — the old
|
||||
single ``diff.append(...nodes)`` spread threw RangeError past engine
|
||||
spread-arity limits, killing the tool card (and the approval gate) for
|
||||
the batch. The raw result body clamps at RAW_CAP so one multi-MB tool
|
||||
output can't become a multi-MB pre-wrap text node rebuilt on every
|
||||
re-render."""
|
||||
body = _body()
|
||||
assert "MAX_PREVIEW_LINES" in body
|
||||
assert "diff.append(...nodes)" not in body, (
|
||||
"preview nodes must append incrementally, not via one spread call"
|
||||
)
|
||||
assert "more preview lines not shown" in body
|
||||
assert "RAW_CAP" in body
|
||||
assert "truncated for display" in body
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,7 @@ def _make_ws(**overrides: Any) -> Workstream:
|
||||
|
||||
def test_emit_created_calls_collector_with_coord_fields() -> None:
|
||||
adapter, collector = _make_adapter()
|
||||
ws = _make_ws()
|
||||
ws = _make_ws(project_id="p1")
|
||||
adapter.emit_created(ws)
|
||||
collector.emit_console_ws_created.assert_called_once_with(
|
||||
"coord-1",
|
||||
@@ -82,6 +82,8 @@ def test_emit_created_calls_collector_with_coord_fields() -> None:
|
||||
kind=WorkstreamKind.COORDINATOR.value,
|
||||
state=WorkstreamState.IDLE.value,
|
||||
parent_ws_id=None,
|
||||
# Tenancy-load-bearing: the console SSE filter gates on this.
|
||||
project_id="p1",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -520,6 +520,7 @@ def test_active_list_row_shape_includes_unified_fields(storage):
|
||||
"kind",
|
||||
"parent_ws_id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
}
|
||||
assert row["name"] == "lifted-coord"
|
||||
assert row["kind"] == "coordinator"
|
||||
|
||||
@@ -245,3 +245,168 @@ def test_controller_terminal_dead_state() -> None:
|
||||
assert "base: base," in body, "the controller must expose its transport base"
|
||||
# Dead controllers don't reconnect on re-auth.
|
||||
assert "if (connected && !dead) pane._loadHistoryThenConnect(wsId);" in body
|
||||
|
||||
|
||||
def test_stream_pipeline_is_wedge_proof() -> None:
|
||||
"""Long-session hardening (perf audit P0): the SSE pipeline must not be
|
||||
able to permanently wedge the pane. ``onmessage`` guards BOTH the
|
||||
``JSON.parse`` and the ``handleEvent`` dispatch (an exception escaping it
|
||||
doesn't close the EventSource, so an unguarded throw left the streaming
|
||||
refs poisoned for the rest of the session), and ``stream_end`` resets the
|
||||
segment refs BEFORE the finalize render, with a plain-text fallback —
|
||||
with the old order a finalize throw skipped the clears and every later
|
||||
delta painted into the dead segment."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
assert "dropping malformed SSE frame" in body
|
||||
assert "handleEvent failed for" in body
|
||||
case = body.index('case "stream_end"')
|
||||
seg = body[case : body.index("break;", case)]
|
||||
clears = seg.index("this.currentAssistantBodyEl = null;")
|
||||
finalize = seg.index("streamingRenderFinalize(")
|
||||
assert clears < finalize, (
|
||||
"stream_end must clear segment refs BEFORE finalize — the old "
|
||||
"finalize-first order wedged all later assistant output on a throw."
|
||||
)
|
||||
assert "doneBodyEl.textContent = doneBuffer;" in seg
|
||||
|
||||
|
||||
def test_rebuild_quiesces_live_events_and_releases_agent_tracking() -> None:
|
||||
"""clear_ui / replay_truncated re-render race (perf audit P0): live SSE
|
||||
events painted between the history snapshot and ``replaceChildren()``
|
||||
were wiped with no redelivery, and streaming refs kept pointing at
|
||||
detached nodes. Pinned: the quiesce queue sits on the handleEvent hot
|
||||
path, both re-render triggers arm it, ``replayHistory`` resets the
|
||||
streaming refs and clears the agent-card/orphan maps (the detached-DOM
|
||||
retention leak), and the mid-stream guard covers the reasoning bubble."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
assert "this._replayQueue.events.push(evt);" in body
|
||||
assert body.count("this._beginReplayQuiesce(") >= 2, (
|
||||
"both clear_ui and replay_truncated must arm the quiesce"
|
||||
)
|
||||
assert "!this.currentAssistantEl && !this.currentReasoningEl" in body
|
||||
replay = body.index("replayHistory(messages) {")
|
||||
seg = body[replay : replay + 1600]
|
||||
for line in (
|
||||
"this._resetStreamingRefs();",
|
||||
"this._clearAgentTracking();",
|
||||
):
|
||||
assert line in seg, f"replayHistory must reset: {line!r}"
|
||||
assert "this._agentCards.clear();" in body
|
||||
# Review-hardened lifecycle: the card entry SURVIVES the terminal
|
||||
# tool_result (a late child event finding no Map entry would rebuild a
|
||||
# duplicate empty card beside the finished one), and transport-only
|
||||
# reconnects preserve the maps + any armed quiesce queue — clearing them
|
||||
# in disconnectSSE duplicated cards and dropped buffered orphan steps on
|
||||
# every transient stream blip. Full-reload cleanup lives in
|
||||
# _loadHistoryThenConnect; terminal cleanup in the factory's destroy().
|
||||
assert "this._agentCards.delete(callId);" not in body
|
||||
disc = body.index("disconnectSSE() {")
|
||||
disc_seg = body[disc : body.index("_loadHistoryThenConnect(wsId) {", disc)]
|
||||
assert "this._clearAgentTracking();" not in disc_seg
|
||||
assert "this._replayQueue = null;" not in disc_seg
|
||||
load = body.index("_loadHistoryThenConnect(wsId) {")
|
||||
load_seg = body[load : load + 2200]
|
||||
assert "this._clearAgentTracking();" in load_seg
|
||||
assert "this._replayQueue = null;" in load_seg
|
||||
# A mid-stream replay_truncated DEFERS the re-sync (flag consumed on the
|
||||
# idle edge) instead of dropping it — skipping left the lost-event gap
|
||||
# unrepaired for the rest of the session.
|
||||
assert "this._pendingTruncatedResync = true;" in body
|
||||
# The refetch FAILURE branch resets streaming refs too — it never reaches
|
||||
# replayHistory, and stale refs there streamed the retried generation's
|
||||
# first segment into a detached bubble.
|
||||
fail = body.index("Failure path never reaches replayHistory")
|
||||
assert "this._resetStreamingRefs();" in body[fail : fail + 400], (
|
||||
"the refetch failure branch must reset streaming refs"
|
||||
)
|
||||
|
||||
|
||||
def test_per_token_hot_path_avoids_container_scans() -> None:
|
||||
"""P1 (perf audit): per-token work must stay O(1) in transcript length.
|
||||
The thinking indicator is an instance ref (the class-selector miss walked
|
||||
the whole transcript on EVERY content/reasoning delta); near-bottom state
|
||||
comes from the passive scroll listener instead of a forced-layout
|
||||
geometry read per event; the scroll pin is rAF-coalesced; per-tool
|
||||
row/stream lookups resolve through the self-healing caches."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
stripped = _strip_comments(body)
|
||||
assert 'querySelector(".thinking-indicator")' not in stripped, (
|
||||
"thinking indicator must use the instance ref, not a container scan"
|
||||
)
|
||||
assert "this._thinkingEl" in body
|
||||
near = body.index("isNearBottom() {")
|
||||
assert "return this._nearBottom;" in body[near : near + 700]
|
||||
assert "passive: true" in body
|
||||
# The rAF pin re-checks the flag AT FIRE TIME (a user scroll landing in
|
||||
# the schedule→rAF window must win over a stale pin), with force
|
||||
# requests latched across the coalescing window; resizes re-derive the
|
||||
# flag via ResizeObserver since they move the bottom without a scroll.
|
||||
assert "this._scrollPinForce = false;" in body
|
||||
assert "ResizeObserver" in body
|
||||
for helper in ("_toolRow(callId) {", "_streamEl(callId) {"):
|
||||
assert helper in body, f"missing lookup-cache helper: {helper!r}"
|
||||
|
||||
|
||||
_INTERACTIVE_CSS = _ROOT / "turnstone/shared_static/interactive.css"
|
||||
_UI_STYLE_CSS = _ROOT / "turnstone/ui/static/style.css"
|
||||
|
||||
|
||||
def test_transcript_scroller_is_block_flow_with_containment() -> None:
|
||||
"""P2 (perf audit): the messages scroller is BLOCK flow — a column
|
||||
flexbox relayouts every row when the streaming row's height changes,
|
||||
O(rows) per token — with native scroll anchoring disabled (the pane owns
|
||||
bottom pinning, and the browser's anchor node lives inside the
|
||||
innerHTML-replaced live bubble). Off-screen rows carry
|
||||
content-visibility:auto with `auto`-keyword intrinsic sizing; the live
|
||||
tail (last two children) is exempt so the streaming bubble never toggles
|
||||
skip-state mid-stream."""
|
||||
css = _INTERACTIVE_CSS.read_text(encoding="utf-8")
|
||||
rule = css.index(".pane--embedded .pane-messages {")
|
||||
body = css[rule : css.index("}", rule)]
|
||||
assert "display: flex" not in body, "scroller must be block flow"
|
||||
assert "overflow-anchor: none" in body
|
||||
assert ".pane--embedded .pane-messages > * + *" in css, (
|
||||
"inter-row rhythm must come from sibling margins, not flex gap"
|
||||
)
|
||||
assert "content-visibility: auto" in css
|
||||
assert "contain-intrinsic-size: auto" in css
|
||||
assert ":nth-last-child(-n + 2)" in css, "live tail must be exempt"
|
||||
ui = _UI_STYLE_CSS.read_text(encoding="utf-8")
|
||||
ui_rule = ui.index(".pane-messages {")
|
||||
ui_body = ui[ui_rule : ui.index("}", ui_rule)]
|
||||
assert "display: flex" not in ui_body, "ui/static duplicate must match"
|
||||
assert "overflow-anchor: none" in ui_body
|
||||
|
||||
|
||||
def test_transcript_is_windowed_with_pager() -> None:
|
||||
"""P2 (perf audit): full re-renders paint only the most recent
|
||||
_HISTORY_WINDOW_STEP messages, cut FORWARD to a user-turn boundary so an
|
||||
assistant tool_calls message is never split from the tool results that
|
||||
anchor to it; hidden content sits behind the .msg-history-pager button
|
||||
(click grows the window and refetches with a scroll-anchor restore).
|
||||
Live appends are bounded at the idle edge by _LIVE_ROW_CAP, trimming
|
||||
only while pinned (a scrolled-up user is reading the rows a trim would
|
||||
remove) and sweeping detached agent-card entries."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
assert "const _HISTORY_WINDOW_STEP = 300;" in body
|
||||
assert "const _LIVE_ROW_CAP = 900;" in body
|
||||
replay = body.index("replayHistory(messages) {")
|
||||
seg = body[replay : replay + 4200]
|
||||
assert 'messages[start].role !== "user"' in seg, (
|
||||
"the window cut must land on a user-turn boundary"
|
||||
)
|
||||
assert "_addHistoryPager" in seg
|
||||
assert "for (let i = start; i < messages.length; i++)" in seg
|
||||
assert 'pager.className = "msg-history-pager";' in body
|
||||
assert "this._historyWindow += _HISTORY_WINDOW_STEP;" in body
|
||||
trim = body.index("_trimLiveTranscript() {")
|
||||
trim_seg = body[trim : trim + 2600]
|
||||
assert "if (!this._nearBottom) return;" in trim_seg, (
|
||||
"live trim must only run while pinned to the bottom"
|
||||
)
|
||||
assert "card.wrap.isConnected" in trim_seg, "live trim must sweep detached agent-card entries"
|
||||
# Rewind/edit turn math is tail-relative (counts user rows at-or-AFTER
|
||||
# the clicked one), which is what makes hiding EARLIER rows safe — pin
|
||||
# the tail-relative form so a refactor to absolute indexing fails here
|
||||
# and gets re-checked against windowing.
|
||||
assert body.count("userMsgs.length - idx") >= 2
|
||||
|
||||
@@ -1936,3 +1936,27 @@ class TestInternalMcpStatusEndpoint:
|
||||
r = c.get("/v1/api/_internal/mcp-status")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"servers": {}}
|
||||
|
||||
def test_status_aggregate_gated_on_admin_mcp_permission(self, storage: SQLiteBackend) -> None:
|
||||
"""oauth_user status is cross-user-aggregated ONLY for callers holding
|
||||
admin.mcp (the console cluster-health view). A read/approve user without
|
||||
it gets aggregate=False — strictly their own pool, the leak guard."""
|
||||
|
||||
def _aggregate_arg(middleware_cls: type) -> Any:
|
||||
mgr = MagicMock()
|
||||
mgr.get_all_server_status.return_value = {}
|
||||
app = Starlette(
|
||||
routes=_routes_with_internal(),
|
||||
middleware=[Middleware(middleware_cls)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
app.state.mcp_client = mgr
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
assert client.get("/v1/api/_internal/mcp-status").status_code == 200
|
||||
return mgr.get_all_server_status.call_args
|
||||
|
||||
admin_call = _aggregate_arg(_InjectAuthMiddleware)
|
||||
assert admin_call.kwargs.get("aggregate") is True
|
||||
|
||||
user_call = _aggregate_arg(_InjectAuthNoMcpMiddleware)
|
||||
assert user_call.kwargs.get("aggregate") is False
|
||||
|
||||
@@ -17,6 +17,7 @@ from tests.conftest import _seed_static_state
|
||||
from turnstone.core.mcp_client import (
|
||||
MCPClientManager,
|
||||
_db_servers_to_config,
|
||||
_is_dead_transport,
|
||||
_mcp_to_openai,
|
||||
load_mcp_config,
|
||||
)
|
||||
@@ -2487,6 +2488,326 @@ class TestCircuitBreaker:
|
||||
# Circuit should NOT have recorded a failure
|
||||
assert mgr._consecutive_failures.get("test", 0) == 0
|
||||
|
||||
def test_closed_resource_error_evicts_session_and_trips_circuit(self):
|
||||
"""Regression: the MCP SDK's streamable-http transport raises
|
||||
``anyio.ClosedResourceError`` (NOT BrokenPipeError) when its write
|
||||
stream is dead. That must evict the session AND trip the breaker —
|
||||
otherwise the corpse session is re-used on every call forever and
|
||||
only a full process restart recovers it."""
|
||||
import anyio
|
||||
|
||||
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
|
||||
mock_session = MagicMock()
|
||||
mock_session.call_tool = MagicMock(return_value="sentinel")
|
||||
_seed_static_state(mgr, "test", session=mock_session)
|
||||
mgr._loop = MagicMock()
|
||||
mgr._tool_map["mcp__test__ping"] = ("test", "ping")
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = anyio.ClosedResourceError()
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(anyio.ClosedResourceError),
|
||||
):
|
||||
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
|
||||
assert mgr._static_servers["test"].session is None
|
||||
assert mgr._consecutive_failures.get("test", 0) == 1
|
||||
|
||||
def test_session_terminated_mcperror_evicts_and_trips_circuit(self):
|
||||
"""Regression: when the MCP SERVER restarts and loses its session map, our
|
||||
held mcp-session-id is stale; the server returns HTTP 404 and the SDK
|
||||
surfaces McpError(code=32600, 'Session terminated'). That is NOT a healthy
|
||||
protocol rejection — the session must be evicted so the next dispatch
|
||||
reconnects with a fresh initialize; reusing it 404s forever (restart-hang)."""
|
||||
from mcp import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
|
||||
mock_session = MagicMock()
|
||||
mock_session.call_tool = MagicMock(return_value="sentinel")
|
||||
_seed_static_state(mgr, "test", session=mock_session)
|
||||
mgr._loop = MagicMock()
|
||||
mgr._tool_map["mcp__test__ping"] = ("test", "ping")
|
||||
mock_future = MagicMock()
|
||||
# Exactly what the streamable-http SDK injects on a 404 stale session.
|
||||
mock_future.result.side_effect = McpError(
|
||||
ErrorData(code=32600, message="Session terminated")
|
||||
)
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(McpError),
|
||||
):
|
||||
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
|
||||
assert mgr._static_servers["test"].session is None
|
||||
assert mgr._consecutive_failures.get("test", 0) == 1
|
||||
|
||||
def test_httpx_connect_error_evicts_session(self):
|
||||
"""A dead underlying httpx connection (server down mid-call) is transport
|
||||
death, not a protocol rejection — evict so the next call reconnects."""
|
||||
import httpx
|
||||
|
||||
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
|
||||
mock_session = MagicMock()
|
||||
mock_session.call_tool = MagicMock(return_value="sentinel")
|
||||
_seed_static_state(mgr, "test", session=mock_session)
|
||||
mgr._loop = MagicMock()
|
||||
mgr._tool_map["mcp__test__ping"] = ("test", "ping")
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = httpx.ConnectError("connection refused")
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(httpx.ConnectError),
|
||||
):
|
||||
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
|
||||
assert mgr._static_servers["test"].session is None
|
||||
assert mgr._consecutive_failures.get("test", 0) == 1
|
||||
|
||||
def test_connection_closed_mcperror_evicts_and_trips_circuit(self):
|
||||
"""Regression: when the SDK's ``post_writer`` swallows the transport
|
||||
error, a dead connection surfaces as ``McpError(CONNECTION_CLOSED)``.
|
||||
Unlike a genuine protocol rejection, this MUST evict + trip the
|
||||
breaker so the next dispatch reconnects instead of looping."""
|
||||
from mcp import McpError
|
||||
from mcp.types import CONNECTION_CLOSED, ErrorData
|
||||
|
||||
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
|
||||
mock_session = MagicMock()
|
||||
mock_session.call_tool = MagicMock(return_value="sentinel")
|
||||
_seed_static_state(mgr, "test", session=mock_session)
|
||||
mgr._loop = MagicMock()
|
||||
mgr._tool_map["mcp__test__ping"] = ("test", "ping")
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = McpError(
|
||||
ErrorData(code=CONNECTION_CLOSED, message="connection closed")
|
||||
)
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(McpError),
|
||||
):
|
||||
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
|
||||
assert mgr._static_servers["test"].session is None
|
||||
assert mgr._consecutive_failures.get("test", 0) == 1
|
||||
|
||||
def test_refresh_all_evicts_dead_session_so_next_tick_reconnects(self):
|
||||
"""Regression: a periodic refresh that hits a dead-but-non-None
|
||||
session must null the session so the reconnect branch (gated on
|
||||
``session is None``) fires on the NEXT tick. Without this the
|
||||
refresh re-probes the corpse forever — the bug that required a
|
||||
full restart."""
|
||||
import anyio
|
||||
|
||||
async def _run() -> None:
|
||||
mgr = MCPClientManager({})
|
||||
mgr._server_configs["test"] = {"type": "stdio", "command": "x"}
|
||||
dead = anyio.ClosedResourceError()
|
||||
mock_session = MagicMock()
|
||||
mock_session.list_tools = AsyncMock(side_effect=dead)
|
||||
mock_session.list_resources = AsyncMock(side_effect=dead)
|
||||
mock_session.list_resource_templates = AsyncMock(side_effect=dead)
|
||||
mock_session.list_prompts = AsyncMock(side_effect=dead)
|
||||
_seed_static_state(mgr, "test", session=mock_session)
|
||||
|
||||
await mgr._refresh_all("test")
|
||||
|
||||
# Dead session evicted → next refresh tick / dispatch reconnects.
|
||||
assert mgr._static_servers["test"].session is None
|
||||
ts, outcome = mgr._last_refresh["test"]
|
||||
assert outcome == "error:ClosedResourceError"
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_read_resource_sync_dead_transport_evicts_and_trips_circuit(self):
|
||||
"""Regression (follow-up): read_resource_sync kept the old
|
||||
BrokenPipe/ConnectionReset/EOF-only guard, so a dead streamable-http
|
||||
transport surfacing as McpError(CONNECTION_CLOSED) reused the corpse
|
||||
session forever — the exact restart-hang call_tool_sync already fixes.
|
||||
It must now evict the session AND trip the breaker."""
|
||||
from mcp import McpError
|
||||
from mcp.types import CONNECTION_CLOSED, ErrorData
|
||||
|
||||
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
|
||||
mock_session = MagicMock()
|
||||
_seed_static_state(mgr, "test", session=mock_session)
|
||||
mgr._loop = MagicMock()
|
||||
mgr._resource_map = {"file:///x": ("test", "file:///x")}
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = McpError(
|
||||
ErrorData(code=CONNECTION_CLOSED, message="connection closed")
|
||||
)
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(McpError),
|
||||
):
|
||||
mgr.read_resource_sync("file:///x", timeout=5)
|
||||
assert mgr._static_servers["test"].session is None
|
||||
assert mgr._consecutive_failures.get("test", 0) == 1
|
||||
|
||||
def test_read_resource_sync_protocol_mcperror_does_not_evict(self):
|
||||
"""A healthy protocol rejection (resource not found) must NOT evict the
|
||||
session or trip the breaker on the resource path."""
|
||||
from mcp import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
|
||||
mock_session = MagicMock()
|
||||
_seed_static_state(mgr, "test", session=mock_session)
|
||||
mgr._loop = MagicMock()
|
||||
mgr._resource_map = {"file:///x": ("test", "file:///x")}
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = McpError(
|
||||
ErrorData(code=-32602, message="resource not found")
|
||||
)
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(McpError),
|
||||
):
|
||||
mgr.read_resource_sync("file:///x", timeout=5)
|
||||
assert mgr._static_servers["test"].session is mock_session
|
||||
assert mgr._consecutive_failures.get("test", 0) == 0
|
||||
|
||||
def test_get_prompt_sync_dead_transport_evicts_and_trips_circuit(self):
|
||||
"""Regression (follow-up): get_prompt_sync had the same corpse-reuse
|
||||
bug as read_resource_sync. A dead transport (anyio.ClosedResourceError)
|
||||
must evict the session AND trip the breaker."""
|
||||
import anyio
|
||||
|
||||
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
|
||||
mock_session = MagicMock()
|
||||
_seed_static_state(mgr, "test", session=mock_session)
|
||||
mgr._loop = MagicMock()
|
||||
mgr._prompt_map = {"mcp__test__p": ("test", "p")}
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = anyio.ClosedResourceError()
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(anyio.ClosedResourceError),
|
||||
):
|
||||
mgr.get_prompt_sync("mcp__test__p", timeout=5)
|
||||
assert mgr._static_servers["test"].session is None
|
||||
assert mgr._consecutive_failures.get("test", 0) == 1
|
||||
|
||||
def test_get_prompt_sync_protocol_mcperror_does_not_evict(self):
|
||||
"""A healthy protocol rejection must NOT evict on the prompt path."""
|
||||
from mcp import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
|
||||
mock_session = MagicMock()
|
||||
_seed_static_state(mgr, "test", session=mock_session)
|
||||
mgr._loop = MagicMock()
|
||||
mgr._prompt_map = {"mcp__test__p": ("test", "p")}
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = McpError(
|
||||
ErrorData(code=-32602, message="prompt not found")
|
||||
)
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(McpError),
|
||||
):
|
||||
mgr.get_prompt_sync("mcp__test__p", timeout=5)
|
||||
assert mgr._static_servers["test"].session is mock_session
|
||||
assert mgr._consecutive_failures.get("test", 0) == 0
|
||||
|
||||
|
||||
class TestIsDeadTransport:
|
||||
"""Direct unit tests for ``_is_dead_transport`` — the single shared gate
|
||||
that decides 'tear down and rebuild the session' vs 'healthy protocol
|
||||
rejection' across every session-use site."""
|
||||
|
||||
def test_connection_closed_is_dead(self):
|
||||
from mcp import McpError
|
||||
from mcp.types import CONNECTION_CLOSED, ErrorData
|
||||
|
||||
assert _is_dead_transport(
|
||||
McpError(ErrorData(code=CONNECTION_CLOSED, message="connection closed"))
|
||||
)
|
||||
|
||||
def test_sdk_session_terminated_is_dead(self):
|
||||
"""The streamable-http SDK synthesizes EXACTLY code=32600 /
|
||||
'Session terminated' when a held mcp-session-id 404s after a server
|
||||
restart — keyed off the code so it survives a message reword."""
|
||||
from mcp import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
assert _is_dead_transport(McpError(ErrorData(code=32600, message="Session terminated")))
|
||||
|
||||
def test_app_session_not_found_is_not_dead(self):
|
||||
"""#2 regression: a HEALTHY session-owning MCP server (game/shell)
|
||||
rejecting a stale id with 'session not found' is a protocol error, NOT
|
||||
transport death. The old bare-substring match wrongly evicted the live
|
||||
session and tripped the shared breaker for every user."""
|
||||
from mcp import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
assert not _is_dead_transport(
|
||||
McpError(ErrorData(code=-32603, message="Backend session not found"))
|
||||
)
|
||||
|
||||
def test_app_session_terminated_message_is_not_dead(self):
|
||||
"""#8 regression: the message is application-controlled and is NOT matched
|
||||
— only the SDK's synthesized code 32600 is. A healthy session-owning
|
||||
server that returns a protocol error whose message is EXACTLY 'Session
|
||||
terminated' (or a superstring) with a normal code stays breaker-safe."""
|
||||
from mcp import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
# Exact SDK message but an app protocol code (not 32600) — must NOT be dead.
|
||||
assert not _is_dead_transport(
|
||||
McpError(ErrorData(code=-32603, message="Session terminated"))
|
||||
)
|
||||
# Superstring likewise.
|
||||
assert not _is_dead_transport(
|
||||
McpError(ErrorData(code=-32603, message="Player session terminated by host"))
|
||||
)
|
||||
|
||||
def test_plain_protocol_mcperror_is_not_dead(self):
|
||||
from mcp import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
assert not _is_dead_transport(McpError(ErrorData(code=-32601, message="method not found")))
|
||||
|
||||
def test_httpx_read_timeout_is_dead(self):
|
||||
"""#7: an idle read timeout on a long-lived streamable-http stream is
|
||||
the dominant idle-death mode — and is NOT a builtin TimeoutError, so it
|
||||
must be caught here or it falls through to a healthy 'other'."""
|
||||
import httpx
|
||||
|
||||
assert not issubclass(httpx.ReadTimeout, TimeoutError) # premise guard
|
||||
assert _is_dead_transport(httpx.ReadTimeout("read timed out"))
|
||||
|
||||
def test_httpx_pool_timeout_is_not_dead(self):
|
||||
"""PoolTimeout is connection-pool saturation, NOT a dead connection:
|
||||
evicting the session can't relieve pool pressure and would trip the
|
||||
shared breaker for all users under transient load. The Connect/Read/Write
|
||||
timeouts (a dead/hung connection) stay dead."""
|
||||
import httpx
|
||||
|
||||
assert not _is_dead_transport(httpx.PoolTimeout("pool exhausted"))
|
||||
assert _is_dead_transport(httpx.WriteTimeout("write timed out"))
|
||||
|
||||
def test_httpx_read_error_is_dead(self):
|
||||
"""#8: a connection that dies mid-read surfaces as httpx.ReadError (a
|
||||
NetworkError sibling of the already-handled ConnectError)."""
|
||||
import httpx
|
||||
|
||||
assert _is_dead_transport(httpx.ReadError("peer reset"))
|
||||
|
||||
def test_httpx_write_error_is_dead(self):
|
||||
import httpx
|
||||
|
||||
assert _is_dead_transport(httpx.WriteError("broken pipe"))
|
||||
|
||||
def test_httpx_local_protocol_error_is_not_dead(self):
|
||||
"""LocalProtocolError is OUR bug (a malformed request we built), not a
|
||||
dead peer — it must NOT be mistaken for transport death."""
|
||||
import httpx
|
||||
|
||||
assert not _is_dead_transport(httpx.LocalProtocolError("bad header"))
|
||||
|
||||
def test_anyio_closed_resource_is_dead(self):
|
||||
import anyio
|
||||
|
||||
assert _is_dead_transport(anyio.ClosedResourceError())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix 3: Safe transport stream pre-close
|
||||
|
||||
@@ -1609,16 +1609,60 @@ class TestPoolPrimingAndTokenRotation:
|
||||
|
||||
assert primed == [(("user-1", "pool-srv"), "bearer-fresh")]
|
||||
|
||||
def test_prime_user_pools_skips_near_expiry_without_revoking(
|
||||
def test_prime_user_pools_refreshes_expired_token_and_warms(
|
||||
self, running_loop_mgr, storage: SQLiteBackend
|
||||
) -> None:
|
||||
"""bug-1 regression: a near-expiry token is skipped (not refreshed), so a
|
||||
transient refresh failure during priming can never revoke the token."""
|
||||
"""An expired/near-expiry token is now REFRESHED (via the guarded
|
||||
classified resolver) and the pool is warmed with the fresh token —
|
||||
closing the chicken-and-egg where an expired token left the pool
|
||||
permanently cold ("connecting" / no tools / never-refreshed)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.mcp_oauth import TokenLookupResult
|
||||
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
cipher = make_mcp_token_cipher()
|
||||
_seed_oauth_server(storage, name="pool-srv")
|
||||
_seed_user_token(storage, cipher, expires_in_seconds=5, access_token="bearer-stale")
|
||||
self._wire(mgr, storage, cipher)
|
||||
|
||||
primed: list[tuple[tuple[str, str], str]] = []
|
||||
|
||||
async def _fake_prime(
|
||||
self_inner: MCPClientManager, key: tuple[str, str], cfg: dict[str, Any], token: str
|
||||
) -> int:
|
||||
primed.append((key, token))
|
||||
return 3
|
||||
|
||||
mgr._prime_user_server = _fake_prime.__get__(mgr, type(mgr)) # type: ignore[method-assign]
|
||||
|
||||
async def _fake_classified(**_kwargs: Any) -> TokenLookupResult:
|
||||
# The resolver refreshed the expired token and returns the fresh one.
|
||||
return TokenLookupResult(kind="token", token="bearer-refreshed")
|
||||
|
||||
with patch(
|
||||
"turnstone.core.mcp_client.get_user_access_token_classified",
|
||||
side_effect=_fake_classified,
|
||||
):
|
||||
_run_on_loop(loop, mgr._prime_user_pools("user-1"))
|
||||
|
||||
assert primed == [(("user-1", "pool-srv"), "bearer-refreshed")], (
|
||||
"expired token must be refreshed and the pool warmed with the fresh token"
|
||||
)
|
||||
|
||||
def test_prime_user_pools_transient_refresh_failure_skips_without_revoking(
|
||||
self, running_loop_mgr, storage: SQLiteBackend
|
||||
) -> None:
|
||||
"""Safety invariant preserved: a TRANSIENT refresh failure during priming
|
||||
does not warm the pool AND does not revoke — the classified resolver keeps
|
||||
the token (kind=refresh_failed_transient) and lazy dispatch retries later."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.mcp_oauth import TokenLookupResult
|
||||
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
cipher = make_mcp_token_cipher()
|
||||
_seed_oauth_server(storage, name="pool-srv")
|
||||
# Inside the 60s refresh-skew window -> the refreshing lookup would have
|
||||
# driven a refresh here.
|
||||
_seed_user_token(storage, cipher, expires_in_seconds=5, access_token="bearer-stale")
|
||||
self._wire(mgr, storage, cipher)
|
||||
|
||||
@@ -1632,13 +1676,116 @@ class TestPoolPrimingAndTokenRotation:
|
||||
|
||||
mgr._prime_user_server = _fake_prime.__get__(mgr, type(mgr)) # type: ignore[method-assign]
|
||||
|
||||
_run_on_loop(loop, mgr._prime_user_pools("user-1"))
|
||||
async def _fake_classified(**_kwargs: Any) -> TokenLookupResult:
|
||||
return TokenLookupResult(kind="refresh_failed_transient")
|
||||
|
||||
assert primed == [], "near-expiry token must be skipped, not primed (no refresh driven)"
|
||||
# The token row must survive — priming must never revoke.
|
||||
with patch(
|
||||
"turnstone.core.mcp_client.get_user_access_token_classified",
|
||||
side_effect=_fake_classified,
|
||||
):
|
||||
_run_on_loop(loop, mgr._prime_user_pools("user-1"))
|
||||
|
||||
assert primed == [], "transient refresh failure must not warm the pool"
|
||||
# The token row must survive — priming must never revoke on a transient blip.
|
||||
# NOTE: the resolver is stubbed here, so this only covers _prime_user_pools'
|
||||
# handling of a transient result; the actual revoke-vs-keep decision under
|
||||
# the flag prime passes is exercised by
|
||||
# test_non_destructive_resolve_keeps_dead_grant_default_revokes below.
|
||||
store = MCPTokenStore(storage, cipher, node_id="test")
|
||||
assert store.get_user_token("user-1", "pool-srv") is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prime_revokes_permanent_but_defers_ambiguous_escalation(
|
||||
self, storage: SQLiteBackend
|
||||
) -> None:
|
||||
"""Priming resolves with revoke_ambiguous_escalation=False. A PERMANENT
|
||||
rejection (invalid_grant — a reliable dead-grant signal) is STILL revoked
|
||||
so the catalog isn't stranded cold behind a phantom 'consented' token;
|
||||
only a sustained-UNCLASSIFIABLE (ambiguous) escalation is deferred to lazy
|
||||
dispatch. Drives the REAL resolver (only the AS round-trip is stubbed)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.mcp_oauth import (
|
||||
_AMBIGUOUS_ESCALATION_THRESHOLD,
|
||||
MCPOAuthRefreshFailed,
|
||||
_refresh_backoff_state,
|
||||
_RefreshFailureClass,
|
||||
get_user_access_token_classified,
|
||||
)
|
||||
|
||||
cipher = make_mcp_token_cipher()
|
||||
_seed_oauth_server(storage, name="srv-oauth")
|
||||
state = _make_app_state(storage, cipher=cipher)
|
||||
store = MCPTokenStore(storage, cipher, node_id="test")
|
||||
|
||||
def _raiser(cls: _RefreshFailureClass) -> Any:
|
||||
async def _f(**_kwargs: Any) -> tuple[str, str | None, str | None]:
|
||||
raise MCPOAuthRefreshFailed("boom", failure_class=cls)
|
||||
|
||||
return _f
|
||||
|
||||
def _seed(uid: str) -> None:
|
||||
# Expired-with-refresh so each resolve reaches the refresh path.
|
||||
_seed_user_token(
|
||||
storage, cipher, user_id=uid, server_name="srv-oauth", expires_in_seconds=-10
|
||||
)
|
||||
|
||||
# (1) PERMANENT during prime → REVOKED (genuinely dead → clean re-consent).
|
||||
_seed("perm-user")
|
||||
with patch(
|
||||
"turnstone.core.mcp_oauth._refresh_and_persist",
|
||||
side_effect=_raiser(_RefreshFailureClass.PERMANENT),
|
||||
):
|
||||
perm = await get_user_access_token_classified(
|
||||
app_state=state,
|
||||
user_id="perm-user",
|
||||
server_name="srv-oauth",
|
||||
revoke_ambiguous_escalation=False,
|
||||
)
|
||||
assert perm.kind == "refresh_failed"
|
||||
assert store.get_user_token("perm-user", "srv-oauth") is None, (
|
||||
"prime must revoke a PERMANENT (reliably-dead) grant, not strand it cold"
|
||||
)
|
||||
|
||||
# (2) AMBIGUOUS escalation during prime → DEFERRED (token KEPT).
|
||||
_seed("amb-user")
|
||||
_refresh_backoff_state(state, "amb-user", "srv-oauth").ambiguous_streak = (
|
||||
_AMBIGUOUS_ESCALATION_THRESHOLD - 1
|
||||
)
|
||||
with patch(
|
||||
"turnstone.core.mcp_oauth._refresh_and_persist",
|
||||
side_effect=_raiser(_RefreshFailureClass.AMBIGUOUS),
|
||||
):
|
||||
amb = await get_user_access_token_classified(
|
||||
app_state=state,
|
||||
user_id="amb-user",
|
||||
server_name="srv-oauth",
|
||||
revoke_ambiguous_escalation=False,
|
||||
)
|
||||
assert amb.kind == "refresh_failed_transient"
|
||||
assert store.get_user_token("amb-user", "srv-oauth") is not None, (
|
||||
"prime must DEFER (not revoke) a sustained-ambiguous escalation"
|
||||
)
|
||||
|
||||
# (3) Control: lazy dispatch (default) DOES escalate-revoke the same.
|
||||
_seed("amb-lazy")
|
||||
_refresh_backoff_state(state, "amb-lazy", "srv-oauth").ambiguous_streak = (
|
||||
_AMBIGUOUS_ESCALATION_THRESHOLD - 1
|
||||
)
|
||||
with patch(
|
||||
"turnstone.core.mcp_oauth._refresh_and_persist",
|
||||
side_effect=_raiser(_RefreshFailureClass.AMBIGUOUS),
|
||||
):
|
||||
lazy = await get_user_access_token_classified(
|
||||
app_state=state,
|
||||
user_id="amb-lazy",
|
||||
server_name="srv-oauth",
|
||||
)
|
||||
assert lazy.kind == "refresh_failed"
|
||||
assert store.get_user_token("amb-lazy", "srv-oauth") is None, (
|
||||
"lazy dispatch must still escalate-revoke a sustained-ambiguous grant"
|
||||
)
|
||||
|
||||
def test_prime_user_pools_skips_already_connected(
|
||||
self, running_loop_mgr, storage: SQLiteBackend
|
||||
) -> None:
|
||||
@@ -1840,5 +1987,112 @@ class TestPoolPrimingAndTokenRotation:
|
||||
assert mgr._priming_keys == set(), "in-flight marker must be cleared in finally"
|
||||
|
||||
|
||||
class TestOAuthUserServerStatus:
|
||||
"""``get_server_status`` for ``auth_type='oauth_user'`` servers reflects the
|
||||
REQUESTING user's pool warmth (scoped by user_id), never another user's — so
|
||||
the console pill flips to connected once that user's pool is primed, without
|
||||
leaking one user's catalog to another."""
|
||||
|
||||
@staticmethod
|
||||
def _warm(mgr: MCPClientManager, user_id: str, server: str, n_tools: int = 1) -> None:
|
||||
from turnstone.core.mcp_client import PoolEntryState
|
||||
|
||||
entry = PoolEntryState(key=(user_id, server), open_lock=MagicMock())
|
||||
entry.session = MagicMock()
|
||||
entry.tools = [{"function": {"name": f"mcp__{server}__t{i}"}} for i in range(n_tools)]
|
||||
mgr._user_pool_entries[(user_id, server)] = entry
|
||||
|
||||
def test_oauth_user_status_connected_for_own_warm_pool(self) -> None:
|
||||
mgr = MCPClientManager({})
|
||||
mgr._oauth_user_server_names = {"pool-srv"}
|
||||
self._warm(mgr, "user-1", "pool-srv", n_tools=1)
|
||||
|
||||
st = mgr.get_server_status("pool-srv", user_id="user-1")
|
||||
assert st["connected"] is True
|
||||
assert st["tools"] == 1
|
||||
assert st["auth_type"] == "oauth_user"
|
||||
assert st["user_pools"] == 1
|
||||
# Also surfaced in the all-servers map (oauth_user is absent from
|
||||
# _server_configs, so this exercises the explicit union).
|
||||
assert "pool-srv" in mgr.get_all_server_status(user_id="user-1")
|
||||
|
||||
def test_oauth_user_status_does_not_leak_other_users_pool(self) -> None:
|
||||
"""#4 regression: user B must NOT see user A's warm pool — neither the
|
||||
connected flag nor the catalog count. Before scoping, status was derived
|
||||
from warm[0] (an arbitrary user), leaking A's catalog size to B over the
|
||||
read-scoped /mcp-status endpoint."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._oauth_user_server_names = {"pool-srv"}
|
||||
self._warm(mgr, "user-A", "pool-srv", n_tools=5)
|
||||
|
||||
own = mgr.get_server_status("pool-srv", user_id="user-A")
|
||||
assert own["connected"] is True
|
||||
assert own["tools"] == 5
|
||||
|
||||
other = mgr.get_server_status("pool-srv", user_id="user-B")
|
||||
assert other["connected"] is False, "user B must not see user A's pool as connected"
|
||||
assert other["tools"] == 0, "user B must not see user A's catalog size"
|
||||
assert other["user_pools"] == 0
|
||||
|
||||
def test_oauth_user_status_no_user_context_is_not_connected(self) -> None:
|
||||
"""A request with no user context (user_id falsy — e.g. an operator
|
||||
refresh/reconnect) reports not-connected rather than an arbitrary
|
||||
user's pool."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._oauth_user_server_names = {"pool-srv"}
|
||||
self._warm(mgr, "user-A", "pool-srv", n_tools=3)
|
||||
|
||||
for uid in (None, ""):
|
||||
st = mgr.get_server_status("pool-srv", user_id=uid)
|
||||
assert st["connected"] is False, f"user_id={uid!r} must not see a pool"
|
||||
assert st["tools"] == 0
|
||||
assert st["user_pools"] == 0
|
||||
assert st["auth_type"] == "oauth_user"
|
||||
|
||||
def test_oauth_user_status_connecting_when_no_warm_pool(self) -> None:
|
||||
mgr = MCPClientManager({})
|
||||
mgr._oauth_user_server_names = {"pool-srv"}
|
||||
st = mgr.get_server_status("pool-srv", user_id="user-1")
|
||||
assert st["connected"] is False
|
||||
assert st["tools"] == 0
|
||||
assert st["user_pools"] == 0
|
||||
assert st["auth_type"] == "oauth_user"
|
||||
|
||||
def test_oauth_user_status_aggregate_sees_any_user_pool(self) -> None:
|
||||
"""Admin cluster-health view (aggregate=True, gated on admin.mcp at the
|
||||
endpoint): connected + a representative catalog reflect ANY user's warm
|
||||
pool, so the operator "in use by anyone" pill works — while a non-admin
|
||||
caller (aggregate=False) still sees only their own pool."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._oauth_user_server_names = {"pool-srv"}
|
||||
self._warm(mgr, "user-A", "pool-srv", n_tools=4)
|
||||
|
||||
# Aggregate: a different (or absent) user still sees the server in use.
|
||||
agg = mgr.get_server_status("pool-srv", user_id="user-B", aggregate=True)
|
||||
assert agg["connected"] is True
|
||||
assert agg["tools"] == 4
|
||||
assert agg["user_pools"] == 1
|
||||
assert mgr.get_server_status("pool-srv", user_id=None, aggregate=True)["connected"] is True
|
||||
|
||||
# Non-aggregate stays strictly per-user (no cross-user disclosure).
|
||||
assert mgr.get_server_status("pool-srv", user_id="user-B")["connected"] is False
|
||||
|
||||
def test_public_server_status_uses_aggregate_for_operator_endpoints(self) -> None:
|
||||
"""#1 regression: the approve-scoped operator refresh/reconnect endpoints
|
||||
(_public_server_status) must report a warm oauth_user server as connected
|
||||
via the aggregate view — not the per-user default (user_id=None), which
|
||||
would render every in-use oauth_user server disconnected/empty right after
|
||||
a successful refresh."""
|
||||
from turnstone.server import _public_server_status
|
||||
|
||||
mgr = MCPClientManager({})
|
||||
mgr._oauth_user_server_names = {"pool-srv"}
|
||||
self._warm(mgr, "user-A", "pool-srv", n_tools=2)
|
||||
|
||||
status = _public_server_status(mgr, "pool-srv")
|
||||
assert status["connected"] is True
|
||||
assert status["tools"] == 2
|
||||
|
||||
|
||||
# Suppress unused-import warning for AsyncMock.
|
||||
_ = AsyncMock
|
||||
|
||||
@@ -15,6 +15,7 @@ from turnstone.core.model_registry import (
|
||||
detect_model,
|
||||
load_model_registry,
|
||||
)
|
||||
from turnstone.core.trajectory import Turn
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ModelConfig
|
||||
@@ -1244,8 +1245,8 @@ class TestSessionAgentModel:
|
||||
agent_client.chat.completions.create = fake_create
|
||||
|
||||
agent_msgs = [
|
||||
{"role": "developer", "content": "You are an agent."},
|
||||
{"role": "user", "content": "Do something."},
|
||||
Turn.system("You are an agent."),
|
||||
Turn.user("Do something."),
|
||||
]
|
||||
session._run_agent(agent_msgs)
|
||||
assert captured_model == "agent-model"
|
||||
@@ -1335,14 +1336,14 @@ class TestSessionAgentModel:
|
||||
reg = self._three_model_registry(agent_model="smart", task_model="fast")
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
captured = self._capture(reg, "fast")
|
||||
session._run_agent([{"role": "user", "content": "x"}], label="task")
|
||||
session._run_agent([Turn.user("x")], label="task")
|
||||
assert captured["model"] == "fast-model"
|
||||
|
||||
def test_plan_falls_back_to_agent_model(self) -> None:
|
||||
reg = self._three_model_registry(agent_model="fast")
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
captured = self._capture(reg, "fast")
|
||||
session._run_agent([{"role": "user", "content": "x"}], label="plan")
|
||||
session._run_agent([Turn.user("x")], label="plan")
|
||||
assert captured["model"] == "fast-model"
|
||||
|
||||
def test_plan_uses_session_model_when_no_overrides(self) -> None:
|
||||
@@ -1351,7 +1352,7 @@ class TestSessionAgentModel:
|
||||
reg = self._three_model_registry()
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
captured = self._capture_on(session.client)
|
||||
session._run_agent([{"role": "user", "content": "x"}], label="plan")
|
||||
session._run_agent([Turn.user("x")], label="plan")
|
||||
assert captured["model"] == "test-model"
|
||||
|
||||
def test_task_effort_inherits_session_when_unset(self) -> None:
|
||||
@@ -1362,7 +1363,7 @@ class TestSessionAgentModel:
|
||||
reg = self._three_model_registry()
|
||||
session = _make_session(registry=reg, model_alias="main", reasoning_effort="low")
|
||||
captured = self._capture_on(session.client)
|
||||
session._run_agent([{"role": "user", "content": "x"}], label="task")
|
||||
session._run_agent([Turn.user("x")], label="task")
|
||||
assert self._captured_effort(captured) == "low"
|
||||
|
||||
def test_agent_model_routes_both_plan_and_task(self) -> None:
|
||||
@@ -1372,20 +1373,18 @@ class TestSessionAgentModel:
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
|
||||
plan_captured = self._capture(reg, "fast")
|
||||
session._run_agent([{"role": "user", "content": "x"}], label="plan")
|
||||
session._run_agent([Turn.user("x")], label="plan")
|
||||
assert plan_captured["model"] == "fast-model"
|
||||
|
||||
task_captured = self._capture(reg, "fast")
|
||||
session._run_agent([{"role": "user", "content": "y"}], label="task")
|
||||
session._run_agent([Turn.user("y")], label="task")
|
||||
assert task_captured["model"] == "fast-model"
|
||||
|
||||
def test_explicit_effort_wins_over_registry(self) -> None:
|
||||
reg = self._three_model_registry(task_effort="low")
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
captured = self._capture_on(session.client)
|
||||
session._run_agent(
|
||||
[{"role": "user", "content": "x"}], label="task", reasoning_effort="minimal"
|
||||
)
|
||||
session._run_agent([Turn.user("x")], label="task", reasoning_effort="minimal")
|
||||
assert self._captured_effort(captured) == "minimal"
|
||||
|
||||
# -- per-call agent_alias override (LLM passes model="<alias>") ----------
|
||||
@@ -1395,7 +1394,7 @@ class TestSessionAgentModel:
|
||||
reg = self._three_model_registry()
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
captured = self._capture(reg, "fast")
|
||||
session._run_agent([{"role": "user", "content": "x"}], label="task", agent_alias="fast")
|
||||
session._run_agent([Turn.user("x")], label="task", agent_alias="fast")
|
||||
assert captured["model"] == "fast-model"
|
||||
|
||||
def test_session_fallback_inherits_primary_alias_for_caps(self) -> None:
|
||||
@@ -1426,7 +1425,7 @@ class TestSessionAgentModel:
|
||||
session._resolve_capabilities = spy_resolve # type: ignore[method-assign]
|
||||
|
||||
self._capture_on(session.client) # patch client.chat.completions.create
|
||||
session._run_agent([{"role": "user", "content": "x"}], label="plan")
|
||||
session._run_agent([Turn.user("x")], label="plan")
|
||||
|
||||
assert captured_extra_alias and captured_extra_alias[-1] == "main", (
|
||||
f"agent fallback path did not inherit primary alias for extra_params: "
|
||||
@@ -1443,9 +1442,7 @@ class TestSessionAgentModel:
|
||||
reg = self._three_model_registry()
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
with pytest.raises(ValueError, match="Unknown agent_alias"):
|
||||
session._run_agent(
|
||||
[{"role": "user", "content": "x"}], label="plan", agent_alias="bogus"
|
||||
)
|
||||
session._run_agent([Turn.user("x")], label="plan", agent_alias="bogus")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -25,6 +25,7 @@ from turnstone.server import (
|
||||
get_project_endpoint,
|
||||
list_project_members_endpoint,
|
||||
list_projects,
|
||||
project_resources_endpoint,
|
||||
remove_project_member_endpoint,
|
||||
update_project_endpoint,
|
||||
)
|
||||
@@ -104,6 +105,10 @@ def client(storage: SQLiteBackend) -> Iterator[TestClient]:
|
||||
remove_project_member_endpoint,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
Route(
|
||||
"/api/projects/{project_id}/resources",
|
||||
project_resources_endpoint,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -194,3 +199,51 @@ class TestProjectApi:
|
||||
def test_get_missing_404(self, client: TestClient) -> None:
|
||||
r = client.get("/v1/api/projects/nope")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestProjectResources:
|
||||
def _seed(self, client: TestClient, storage: SQLiteBackend) -> str:
|
||||
pid: str = client.post("/v1/api/projects", json={"name": "R"}).json()["project_id"]
|
||||
storage.register_workstream("ws-a", name="alpha", user_id="alice", project_id=pid)
|
||||
storage.register_workstream("ws-b", name="beta", user_id="alice", project_id=pid)
|
||||
storage.register_workstream("ws-x", name="other", user_id="alice")
|
||||
mid = storage.save_message("ws-a", "user", "see attached")
|
||||
storage.save_attachment("a" * 64, "notes.txt", "text/plain", 5, "text", b"hello")
|
||||
storage.set_message_attachments("ws-a", mid, ["a" * 64])
|
||||
storage.create_structured_memory("m1", "fact", "d", "general", "project", pid, "body")
|
||||
return pid
|
||||
|
||||
def test_resources_aggregate(self, client: TestClient, storage: SQLiteBackend) -> None:
|
||||
pid = self._seed(client, storage)
|
||||
r = client.get(f"/v1/api/projects/{pid}/resources")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["project_id"] == pid
|
||||
assert body["name"] == "R"
|
||||
ws_ids = [w["ws_id"] for w in body["workstreams"]]
|
||||
assert set(ws_ids) == {"ws-a", "ws-b"} # ws-x is not in the project
|
||||
atts = body["attachments"]
|
||||
assert len(atts) == 1
|
||||
assert atts[0]["attachment_id"] == "a" * 64
|
||||
assert atts[0]["filename"] == "notes.txt"
|
||||
assert atts[0]["ws_id"] == "ws-a"
|
||||
assert "content" not in atts[0] # metadata only — never the blob
|
||||
assert body["memory_count"] == 1
|
||||
|
||||
def test_resources_empty_project(self, client: TestClient) -> None:
|
||||
pid = client.post("/v1/api/projects", json={"name": "E"}).json()["project_id"]
|
||||
body = client.get(f"/v1/api/projects/{pid}/resources").json()
|
||||
assert body["workstreams"] == []
|
||||
assert body["attachments"] == []
|
||||
assert body["memory_count"] == 0
|
||||
|
||||
def test_resources_missing_404(self, client: TestClient) -> None:
|
||||
assert client.get("/v1/api/projects/nope/resources").status_code == 404
|
||||
|
||||
def test_resources_private_non_member_403(
|
||||
self, client: TestClient, storage: SQLiteBackend
|
||||
) -> None:
|
||||
# Owned by someone else, private — alice holds project.read but no
|
||||
# membership, so the per-project ACL denies.
|
||||
storage.create_project("p-zed", "Z", "zed")
|
||||
assert client.get("/v1/api/projects/p-zed/resources").status_code == 403
|
||||
|
||||
@@ -226,3 +226,56 @@ class TestMemoryScopeLabels:
|
||||
]
|
||||
labels = [r["scope_label"] for r in _enrich_memory_scope_labels(rows, backend)]
|
||||
assert labels == ["Research", "alice", "alice", "planning chat", "", "gone"]
|
||||
|
||||
|
||||
class TestProjectResourceQueries:
|
||||
def test_list_workstreams_for_project_scoped_and_ordered(self, backend: Any) -> None:
|
||||
import sqlalchemy as sa
|
||||
|
||||
backend.create_project("p1", "A", "u1")
|
||||
backend.register_workstream("w-old", name="old", user_id="u1", project_id="p1")
|
||||
backend.register_workstream("w-new", name="new", user_id="u1", project_id="p1")
|
||||
backend.register_workstream("w-out", name="out", user_id="u1")
|
||||
# Force a deterministic ``updated`` ordering directly — same-second
|
||||
# registration timestamps would otherwise make ORDER BY updated
|
||||
# DESC a coin flip and the ordering assertion vacuous.
|
||||
with backend._engine.connect() as conn: # noqa: SLF001
|
||||
conn.execute(
|
||||
sa.text("UPDATE workstreams SET updated = '2020-01-01' WHERE ws_id = 'w-old'")
|
||||
)
|
||||
conn.commit()
|
||||
rows = backend.list_workstreams_for_project("p1")
|
||||
assert [r["ws_id"] for r in rows] == ["w-new", "w-old"]
|
||||
assert {"ws_id", "name", "title", "state", "kind", "updated", "node_id", "user_id"} <= set(
|
||||
rows[0]
|
||||
)
|
||||
|
||||
def test_list_project_attachments_dedupes_to_first_ws(self, backend: Any) -> None:
|
||||
backend.create_project("p1", "A", "u1")
|
||||
backend.register_workstream("w1", user_id="u1", project_id="p1")
|
||||
backend.register_workstream("w2", user_id="u1", project_id="p1")
|
||||
backend.save_attachment("a" * 64, "one.txt", "text/plain", 3, "text", b"abc")
|
||||
backend.save_attachment("b" * 64, "two.png", "image/png", 4, "image", b"pngx")
|
||||
m1 = backend.save_message("w1", "user", "first")
|
||||
backend.set_message_attachments("w1", m1, ["a" * 64])
|
||||
# Same blob referenced again from w2 + a second blob.
|
||||
m2 = backend.save_message("w2", "user", "second")
|
||||
backend.set_message_attachments("w2", m2, ["a" * 64, "b" * 64])
|
||||
atts = backend.list_project_attachments("p1")
|
||||
by_id = {a["attachment_id"]: a for a in atts}
|
||||
assert set(by_id) == {"a" * 64, "b" * 64}
|
||||
assert by_id["a" * 64]["ws_id"] == "w1" # first reference wins
|
||||
assert by_id["b" * 64]["ws_id"] == "w2"
|
||||
assert by_id["a" * 64]["filename"] == "one.txt"
|
||||
assert "content" not in by_id["a" * 64]
|
||||
|
||||
def test_list_project_attachments_skips_pruned_blob(self, backend: Any) -> None:
|
||||
backend.create_project("p1", "A", "u1")
|
||||
backend.register_workstream("w1", user_id="u1", project_id="p1")
|
||||
m1 = backend.save_message("w1", "user", "ref to a gone blob")
|
||||
backend.set_message_attachments("w1", m1, ["c" * 64]) # never saved
|
||||
assert backend.list_project_attachments("p1") == []
|
||||
|
||||
def test_list_project_attachments_empty_project(self, backend: Any) -> None:
|
||||
backend.create_project("p1", "A", "u1")
|
||||
assert backend.list_project_attachments("p1") == []
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
"""Private-project workstream visibility enforcement.
|
||||
|
||||
Covers the tenancy predicate (:class:`WorkstreamProjectVisibility`), the
|
||||
create-time attach gate (:func:`ensure_project_attachable`), the row-access
|
||||
gate in :func:`resolve_workstream_owner`, and the saved-list filter in
|
||||
``_collect_saved_rows`` — the choke points that keep workstreams attached
|
||||
to a private project out of non-members' listings and 403 their direct
|
||||
access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
WorkstreamProjectVisibility,
|
||||
ensure_project_attachable,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def _fake_storage(
|
||||
*,
|
||||
visibility: str = "private",
|
||||
owner: str = "alice",
|
||||
members: tuple[str, ...] = (),
|
||||
missing: bool = False,
|
||||
) -> MagicMock:
|
||||
storage = MagicMock()
|
||||
if missing:
|
||||
storage.get_project.return_value = None
|
||||
else:
|
||||
storage.get_project.return_value = {
|
||||
"project_id": "p1",
|
||||
"name": "P1",
|
||||
"owner_id": owner,
|
||||
"visibility": visibility,
|
||||
"state": "active",
|
||||
}
|
||||
storage.is_project_member.side_effect = lambda pid, uid: uid in members
|
||||
return storage
|
||||
|
||||
|
||||
class _FakeAuth:
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
scopes: tuple[str, ...] = (),
|
||||
permissions: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self._scopes = set(scopes)
|
||||
self._permissions = set(permissions)
|
||||
|
||||
def has_scope(self, scope: str) -> bool:
|
||||
return scope in self._scopes
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
return permission in self._permissions
|
||||
|
||||
|
||||
def _request_for(
|
||||
uid: str,
|
||||
scopes: tuple[str, ...] = (),
|
||||
permissions: tuple[str, ...] = (),
|
||||
) -> Any:
|
||||
return SimpleNamespace(state=SimpleNamespace(auth_result=_FakeAuth(uid, scopes, permissions)))
|
||||
|
||||
|
||||
class TestWsVisiblePredicate:
|
||||
def test_no_project_always_visible(self) -> None:
|
||||
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage())
|
||||
assert vis.ws_visible(None)
|
||||
assert vis.ws_visible("")
|
||||
|
||||
def test_dangling_project_visible(self) -> None:
|
||||
# Project deletion leaves ws links behind — no row, no privacy.
|
||||
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage(missing=True))
|
||||
assert vis.ws_visible("p1")
|
||||
|
||||
def test_public_project_visible_to_anyone(self) -> None:
|
||||
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage(visibility="public"))
|
||||
assert vis.ws_visible("p1")
|
||||
|
||||
def test_private_hidden_from_non_member(self) -> None:
|
||||
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage())
|
||||
assert not vis.ws_visible("p1")
|
||||
|
||||
def test_private_visible_to_project_owner(self) -> None:
|
||||
vis = WorkstreamProjectVisibility("alice", storage=_fake_storage())
|
||||
assert vis.ws_visible("p1")
|
||||
|
||||
def test_private_visible_to_member(self) -> None:
|
||||
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage(members=("bob",)))
|
||||
assert vis.ws_visible("p1")
|
||||
|
||||
def test_private_visible_to_ws_creator(self) -> None:
|
||||
# A workstream's own creator never loses sight of it, even after
|
||||
# a membership revoke leaves a legacy private-project link.
|
||||
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage())
|
||||
assert vis.ws_visible("p1", ws_owner="bob")
|
||||
|
||||
def test_private_hidden_from_anonymous(self) -> None:
|
||||
vis = WorkstreamProjectVisibility("", storage=_fake_storage())
|
||||
assert not vis.ws_visible("p1")
|
||||
|
||||
def test_bypass_sees_everything(self) -> None:
|
||||
vis = WorkstreamProjectVisibility("bob", bypass=True, storage=_fake_storage())
|
||||
assert vis.ws_visible("p1")
|
||||
|
||||
def test_storage_error_fails_closed(self) -> None:
|
||||
storage = MagicMock()
|
||||
storage.get_project.side_effect = RuntimeError("db down")
|
||||
vis = WorkstreamProjectVisibility("bob", storage=storage)
|
||||
assert not vis.ws_visible("p1")
|
||||
|
||||
def test_project_rows_memoized(self) -> None:
|
||||
storage = _fake_storage(visibility="public")
|
||||
vis = WorkstreamProjectVisibility("bob", storage=storage)
|
||||
assert vis.ws_visible("p1")
|
||||
assert vis.ws_visible("p1")
|
||||
assert storage.get_project.call_count == 1
|
||||
|
||||
def test_for_request_bypass_rules(self) -> None:
|
||||
assert WorkstreamProjectVisibility.for_request(
|
||||
_request_for("bob", scopes=("service",))
|
||||
)._bypass
|
||||
assert WorkstreamProjectVisibility.for_request(
|
||||
_request_for("bob", permissions=("admin.cluster.inspect",))
|
||||
)._bypass
|
||||
assert not WorkstreamProjectVisibility.for_request(_request_for("bob"))._bypass
|
||||
|
||||
|
||||
class TestEnsureProjectAttachable:
|
||||
def test_no_project_allowed(self) -> None:
|
||||
assert ensure_project_attachable("bob", "", storage=_fake_storage()) is None
|
||||
|
||||
def test_unknown_project_is_400(self) -> None:
|
||||
denied = ensure_project_attachable("bob", "p1", storage=_fake_storage(missing=True))
|
||||
assert denied is not None and denied[0] == 400
|
||||
|
||||
def test_public_project_allowed(self) -> None:
|
||||
assert (
|
||||
ensure_project_attachable("bob", "p1", storage=_fake_storage(visibility="public"))
|
||||
is None
|
||||
)
|
||||
|
||||
def test_private_member_and_owner_allowed(self) -> None:
|
||||
assert (
|
||||
ensure_project_attachable("bob", "p1", storage=_fake_storage(members=("bob",))) is None
|
||||
)
|
||||
assert ensure_project_attachable("alice", "p1", storage=_fake_storage()) is None
|
||||
|
||||
def test_private_non_member_is_403(self) -> None:
|
||||
denied = ensure_project_attachable("bob", "p1", storage=_fake_storage())
|
||||
assert denied is not None and denied[0] == 403
|
||||
|
||||
def test_anonymous_private_is_403(self) -> None:
|
||||
denied = ensure_project_attachable("", "p1", storage=_fake_storage())
|
||||
assert denied is not None and denied[0] == 403
|
||||
|
||||
def test_storage_error_fails_closed(self) -> None:
|
||||
storage = MagicMock()
|
||||
storage.get_project.side_effect = RuntimeError("db down")
|
||||
denied = ensure_project_attachable("bob", "p1", storage=storage)
|
||||
assert denied is not None and denied[0] == 403
|
||||
|
||||
|
||||
class TestResolveWorkstreamOwnerProjectGate:
|
||||
"""Integration against the real (ephemeral) storage: the row-access
|
||||
gate every interactive ws-scoped verb inherits via tenant_check."""
|
||||
|
||||
def _seed(self, *, member: bool) -> None:
|
||||
from turnstone.core.memory import register_workstream
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
storage.create_project("p1", "Secret", "alice")
|
||||
if member:
|
||||
storage.add_project_member("p1", "bob")
|
||||
register_workstream("ws-priv", user_id="alice", project_id="p1")
|
||||
|
||||
def test_non_member_gets_403(self, tmp_db: str) -> None:
|
||||
from turnstone.core.web_helpers import resolve_workstream_owner
|
||||
|
||||
self._seed(member=False)
|
||||
owner, err = resolve_workstream_owner(_request_for("bob"), "ws-priv")
|
||||
assert err is not None and err.status_code == 403
|
||||
|
||||
def test_member_resolves_owner(self, tmp_db: str) -> None:
|
||||
from turnstone.core.web_helpers import resolve_workstream_owner
|
||||
|
||||
self._seed(member=True)
|
||||
owner, err = resolve_workstream_owner(_request_for("bob"), "ws-priv")
|
||||
assert err is None
|
||||
assert owner == "alice"
|
||||
|
||||
def test_ws_creator_bypasses(self, tmp_db: str) -> None:
|
||||
from turnstone.core.memory import register_workstream
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.web_helpers import resolve_workstream_owner
|
||||
|
||||
storage = get_storage()
|
||||
storage.create_project("p1", "Secret", "alice")
|
||||
# bob created a ws in alice's private project, then lost access —
|
||||
# bob still reaches his own workstream.
|
||||
register_workstream("ws-bob", user_id="bob", project_id="p1")
|
||||
owner, err = resolve_workstream_owner(_request_for("bob"), "ws-bob")
|
||||
assert err is None
|
||||
assert owner == "bob"
|
||||
|
||||
def test_admin_inspect_bypasses(self, tmp_db: str) -> None:
|
||||
from turnstone.core.web_helpers import resolve_workstream_owner
|
||||
|
||||
self._seed(member=False)
|
||||
owner, err = resolve_workstream_owner(
|
||||
_request_for("bob", permissions=("admin.cluster.inspect",)), "ws-priv"
|
||||
)
|
||||
assert err is None
|
||||
assert owner == "alice"
|
||||
|
||||
def test_missing_ws_still_404s(self, tmp_db: str) -> None:
|
||||
from turnstone.core.web_helpers import resolve_workstream_owner
|
||||
|
||||
owner, err = resolve_workstream_owner(_request_for("bob"), "nope")
|
||||
assert err is not None and err.status_code == 404
|
||||
|
||||
def test_public_project_ws_resolves(self, tmp_db: str) -> None:
|
||||
from turnstone.core.memory import register_workstream
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.web_helpers import resolve_workstream_owner
|
||||
|
||||
storage = get_storage()
|
||||
storage.create_project("p1", "Open", "alice")
|
||||
storage.update_project("p1", visibility="public")
|
||||
register_workstream("ws-pub", user_id="alice", project_id="p1")
|
||||
owner, err = resolve_workstream_owner(_request_for("bob"), "ws-pub")
|
||||
assert err is None
|
||||
assert owner == "alice"
|
||||
|
||||
|
||||
class TestSavedListFilter:
|
||||
"""The saved-sessions collector drops private-project rows server-side
|
||||
and carries project_id on surviving rows (real ephemeral DB)."""
|
||||
|
||||
async def test_saved_rows_filtered_and_carry_project_id(self, tmp_db: str) -> None:
|
||||
from turnstone.core.memory import register_workstream, save_message
|
||||
from turnstone.core.session_routes import (
|
||||
SessionEndpointConfig,
|
||||
_collect_saved_rows,
|
||||
)
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
storage = get_storage()
|
||||
storage.create_project("p1", "Secret", "alice")
|
||||
storage.create_project("p2", "Open", "alice")
|
||||
storage.update_project("p2", visibility="public")
|
||||
|
||||
register_workstream("ws-plain", user_id="alice")
|
||||
register_workstream("ws-priv", user_id="alice", project_id="p1")
|
||||
register_workstream("ws-pub", user_id="alice", project_id="p2")
|
||||
register_workstream("ws-own", user_id="bob", project_id="p1")
|
||||
for wid in ("ws-plain", "ws-priv", "ws-pub", "ws-own"):
|
||||
save_message(wid, "user", "hello")
|
||||
|
||||
cfg = SessionEndpointConfig(
|
||||
permission_gate=None,
|
||||
manager_lookup=lambda request: (None, None),
|
||||
tenant_check=None,
|
||||
not_found_label="Workstream not found",
|
||||
audit_action_prefix="workstream",
|
||||
list_kind=WorkstreamKind.INTERACTIVE,
|
||||
saved_state_filter=None,
|
||||
saved_loaded_lookup=None,
|
||||
)
|
||||
|
||||
rows = await _collect_saved_rows(cfg, _request_for("bob"))
|
||||
ids = {r["ws_id"] for r in rows}
|
||||
# bob: no membership in p1 — alice's private ws is dropped; the
|
||||
# public-project ws, the project-less ws, and bob's own
|
||||
# private-project ws all survive.
|
||||
assert ids == {"ws-plain", "ws-pub", "ws-own"}
|
||||
by_id = {r["ws_id"]: r for r in rows}
|
||||
assert by_id["ws-pub"]["project_id"] == "p2"
|
||||
assert by_id["ws-plain"]["project_id"] is None
|
||||
|
||||
rows_alice = await _collect_saved_rows(cfg, _request_for("alice"))
|
||||
assert {r["ws_id"] for r in rows_alice} == {"ws-plain", "ws-priv", "ws-pub", "ws-own"}
|
||||
|
||||
|
||||
class TestTriStateVisibility:
|
||||
def test_undetermined_on_storage_error(self) -> None:
|
||||
storage = MagicMock()
|
||||
storage.get_project.side_effect = RuntimeError("db down")
|
||||
vis = WorkstreamProjectVisibility("bob", storage=storage)
|
||||
assert vis.ws_visibility("p1") is None
|
||||
# The boolean form stays fail-closed.
|
||||
assert vis.ws_visible("p1") is False
|
||||
|
||||
def test_definitive_verdicts(self) -> None:
|
||||
assert (
|
||||
WorkstreamProjectVisibility(
|
||||
"bob", storage=_fake_storage(visibility="public")
|
||||
).ws_visibility("p1")
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
WorkstreamProjectVisibility("bob", storage=_fake_storage()).ws_visibility("p1") is False
|
||||
)
|
||||
|
||||
|
||||
class _ScriptedVis:
|
||||
"""ws_visibility stub: per-pid verdict, or a list consumed per call."""
|
||||
|
||||
def __init__(self, verdicts: dict, bypass: bool = False) -> None:
|
||||
self.verdicts = dict(verdicts)
|
||||
self.bypass = bypass
|
||||
self.calls = 0
|
||||
|
||||
def ws_visibility(self, pid, ws_owner=""):
|
||||
self.calls += 1
|
||||
v = self.verdicts.get(pid or "", True)
|
||||
if isinstance(v, list):
|
||||
return v.pop(0) if len(v) > 1 else v[0]
|
||||
return v
|
||||
|
||||
|
||||
class TestClusterTenancyFilter:
|
||||
def _snap(self):
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "node-a",
|
||||
"workstreams": [
|
||||
{"ws_id": "w-vis", "state": "running", "project_id": "", "user_id": "a"},
|
||||
{"ws_id": "w-priv", "state": "running", "project_id": "ph", "user_id": "a"},
|
||||
],
|
||||
}
|
||||
],
|
||||
"overview": {
|
||||
"nodes": 1,
|
||||
"workstreams": 2,
|
||||
"states": {"running": 2, "thinking": 0, "idle": 0},
|
||||
},
|
||||
}
|
||||
|
||||
def test_snapshot_filters_rows_and_rederives_overview(self) -> None:
|
||||
from turnstone.console.server import _ClusterTenancyFilter
|
||||
|
||||
filt = _ClusterTenancyFilter(_ScriptedVis({"ph": False}))
|
||||
snap = filt.filter_snapshot(self._snap())
|
||||
assert [w["ws_id"] for w in snap["nodes"][0]["workstreams"]] == ["w-vis"]
|
||||
# Overview no longer leaks the hidden row's existence or state.
|
||||
assert snap["overview"]["workstreams"] == 1
|
||||
assert snap["overview"]["states"] == {"running": 1, "thinking": 0, "idle": 0}
|
||||
# Later sparse events for the hidden ws are suppressed.
|
||||
assert filt.event_visible({"type": "cluster_state", "ws_id": "w-priv"}) is False
|
||||
assert filt.event_visible({"type": "cluster_state", "ws_id": "w-vis"}) is True
|
||||
|
||||
def test_bypass_leaves_snapshot_untouched(self) -> None:
|
||||
from turnstone.console.server import _ClusterTenancyFilter
|
||||
|
||||
filt = _ClusterTenancyFilter(_ScriptedVis({"ph": False}, bypass=True))
|
||||
snap = filt.filter_snapshot(self._snap())
|
||||
assert len(snap["nodes"][0]["workstreams"]) == 2
|
||||
assert snap["overview"]["workstreams"] == 2 # collector aggregate preserved
|
||||
assert filt.event_visible({"type": "cluster_state", "ws_id": "w-priv"}) is True
|
||||
assert filt.event_touches_storage({"type": "ws_created", "ws_id": "x"}) is False
|
||||
|
||||
def test_ws_created_judged_and_closed_cleans_up(self) -> None:
|
||||
from turnstone.console.server import _ClusterTenancyFilter
|
||||
|
||||
filt = _ClusterTenancyFilter(_ScriptedVis({"ph": False}))
|
||||
created = {"type": "ws_created", "ws_id": "w1", "project_id": "ph", "user_id": "b"}
|
||||
assert filt.event_visible(created) is False
|
||||
assert filt.event_visible({"type": "ws_rename", "ws_id": "w1"}) is False
|
||||
# The close of a never-shown workstream is itself suppressed…
|
||||
assert filt.event_visible({"type": "ws_closed", "ws_id": "w1"}) is False
|
||||
# …and the state is cleaned, so an unrelated later event passes.
|
||||
assert filt.event_visible({"type": "cluster_state", "ws_id": "w1"}) is True
|
||||
|
||||
def test_undetermined_suppresses_then_retries(self) -> None:
|
||||
from turnstone.console.server import _ClusterTenancyFilter
|
||||
|
||||
vis = _ScriptedVis({"pu": [None, True]})
|
||||
filt = _ClusterTenancyFilter(vis)
|
||||
created = {"type": "ws_created", "ws_id": "w1", "project_id": "pu", "user_id": "b"}
|
||||
# Storage blip: suppressed but NOT pinned hidden.
|
||||
assert filt.event_visible(created) is False
|
||||
assert "w1" in filt._unresolved
|
||||
# Within the retry interval later events stay suppressed without
|
||||
# re-hitting storage.
|
||||
calls_before = vis.calls
|
||||
assert filt.event_visible({"type": "cluster_state", "ws_id": "w1"}) is False
|
||||
assert vis.calls == calls_before
|
||||
# Past the interval the row is re-judged and recovers.
|
||||
filt._RETRY_INTERVAL_S = 0.0
|
||||
filt._retry_after["w1"] = 0.0
|
||||
assert filt.event_touches_storage({"type": "cluster_state", "ws_id": "w1"}) is True
|
||||
assert filt.event_visible({"type": "cluster_state", "ws_id": "w1"}) is True
|
||||
assert "w1" not in filt._unresolved
|
||||
|
||||
def test_denied_verdict_pins_hidden(self) -> None:
|
||||
from turnstone.console.server import _ClusterTenancyFilter
|
||||
|
||||
vis = _ScriptedVis({"pu": [None, False]})
|
||||
filt = _ClusterTenancyFilter(vis)
|
||||
filt._RETRY_INTERVAL_S = 0.0
|
||||
assert (
|
||||
filt.event_visible(
|
||||
{"type": "ws_created", "ws_id": "w1", "project_id": "pu", "user_id": "b"}
|
||||
)
|
||||
is False
|
||||
)
|
||||
filt._retry_after["w1"] = 0.0
|
||||
assert filt.event_visible({"type": "cluster_state", "ws_id": "w1"}) is False
|
||||
assert "w1" in filt._hidden and "w1" not in filt._unresolved
|
||||
|
||||
|
||||
class TestCreateValidatorProjectGate:
|
||||
"""The interactive create validator's attach gate: explicit ids are
|
||||
strict, inherited ids tolerate a deleted project (real ephemeral DB)."""
|
||||
|
||||
async def test_inherited_dangling_project_is_stripped(self, tmp_db: str) -> None:
|
||||
from turnstone.core.memory import register_workstream
|
||||
from turnstone.server import _interactive_create_validate_request
|
||||
|
||||
register_workstream("coord-1", user_id="alice", kind="coordinator", project_id="p-gone")
|
||||
body: dict = {"kind": "interactive", "parent_ws_id": "coord-1"}
|
||||
err = await _interactive_create_validate_request(MagicMock(), body, "alice", [])
|
||||
assert err is None
|
||||
assert (body.get("project_id") or "") == ""
|
||||
|
||||
async def test_explicit_unknown_project_still_400s(self, tmp_db: str) -> None:
|
||||
from turnstone.server import _interactive_create_validate_request
|
||||
|
||||
body: dict = {"kind": "interactive", "project_id": "nope"}
|
||||
err = await _interactive_create_validate_request(MagicMock(), body, "alice", [])
|
||||
assert err is not None and err.status_code == 400
|
||||
|
||||
async def test_inherited_private_revoked_membership_403s(self, tmp_db: str) -> None:
|
||||
from turnstone.core.memory import register_workstream
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.server import _interactive_create_validate_request
|
||||
|
||||
get_storage().create_project("p-priv", "P", "zed")
|
||||
register_workstream("coord-2", user_id="alice", kind="coordinator", project_id="p-priv")
|
||||
body: dict = {"kind": "interactive", "parent_ws_id": "coord-2"}
|
||||
err = await _interactive_create_validate_request(MagicMock(), body, "alice", [])
|
||||
assert err is not None and err.status_code == 403
|
||||
|
||||
async def test_inherited_accessible_project_passes(self, tmp_db: str) -> None:
|
||||
from turnstone.core.memory import register_workstream
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.server import _interactive_create_validate_request
|
||||
|
||||
storage = get_storage()
|
||||
storage.create_project("p-ok", "P", "zed")
|
||||
storage.add_project_member("p-ok", "alice")
|
||||
register_workstream("coord-3", user_id="alice", kind="coordinator", project_id="p-ok")
|
||||
body: dict = {"kind": "interactive", "parent_ws_id": "coord-3"}
|
||||
err = await _interactive_create_validate_request(MagicMock(), body, "alice", [])
|
||||
assert err is None
|
||||
assert body["project_id"] == "p-ok"
|
||||
|
||||
|
||||
class TestSavedListPagination:
|
||||
"""The saved-list collector pages past invisible rows instead of
|
||||
letting a post-SQL filter shrink the window."""
|
||||
|
||||
def _row(self, i: int, project_id: str | None) -> tuple:
|
||||
return (
|
||||
f"ws-{i:03d}",
|
||||
None,
|
||||
None,
|
||||
f"n{i}",
|
||||
"2026-01-01T00:00:00",
|
||||
f"{99999 - i}", # updated: descending with i
|
||||
1,
|
||||
"node-a",
|
||||
"idle",
|
||||
"interactive",
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
project_id,
|
||||
"alice",
|
||||
)
|
||||
|
||||
def _cfg(self):
|
||||
from turnstone.core.session_routes import SessionEndpointConfig
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
return SessionEndpointConfig(
|
||||
permission_gate=None,
|
||||
manager_lookup=lambda request: (None, None),
|
||||
tenant_check=None,
|
||||
not_found_label="Workstream not found",
|
||||
audit_action_prefix="workstream",
|
||||
list_kind=WorkstreamKind.INTERACTIVE,
|
||||
saved_state_filter=None,
|
||||
saved_loaded_lookup=None,
|
||||
)
|
||||
|
||||
def _patch(self, monkeypatch: pytest.MonkeyPatch, rows: list) -> None:
|
||||
def _fake(limit=20, *, kind=None, user_id=None, state=None, offset=0):
|
||||
return rows[offset : offset + limit]
|
||||
|
||||
monkeypatch.setattr("turnstone.core.memory.list_workstreams_with_history", _fake)
|
||||
vis = WorkstreamProjectVisibility("bob", storage=_fake_storage()) # denies any pid
|
||||
monkeypatch.setattr(
|
||||
WorkstreamProjectVisibility,
|
||||
"for_request",
|
||||
classmethod(lambda cls, request, storage=None: vis),
|
||||
)
|
||||
|
||||
async def test_pages_past_invisible_rows(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from turnstone.core.session_routes import _collect_saved_rows
|
||||
|
||||
rows = [self._row(i, "ph") for i in range(60)] + [
|
||||
self._row(i, None) for i in range(60, 130)
|
||||
]
|
||||
self._patch(monkeypatch, rows)
|
||||
result = await _collect_saved_rows(self._cfg(), MagicMock())
|
||||
assert len(result) == 50
|
||||
assert result[0]["ws_id"] == "ws-060"
|
||||
assert result[-1]["ws_id"] == "ws-109"
|
||||
|
||||
async def test_scan_cap_terminates(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from turnstone.core.session_routes import _collect_saved_rows
|
||||
|
||||
rows = [self._row(i, "ph") for i in range(5000)]
|
||||
self._patch(monkeypatch, rows)
|
||||
result = await _collect_saved_rows(self._cfg(), MagicMock())
|
||||
assert result == []
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Live-context exclusion for the model-facing recall tool.
|
||||
|
||||
After a compaction the summary is a cache over the originals, not their
|
||||
replacement — recall is the re-derivation path back into them. Scoping it:
|
||||
|
||||
- ``get_compaction_checkpoint`` reads the latest persisted marker's watermark
|
||||
(distinct from ``get_compaction_watermark``, which computes what a NEW
|
||||
compaction would use).
|
||||
- ``search_history(exclude_ws_id=…, exclude_after=…)`` drops the excluded
|
||||
workstream's rows ABOVE the boundary — the live segment already in the
|
||||
model's context — while rows at or below it (the summarized-away past)
|
||||
stay searchable. ``exclude_after=None`` excludes the whole workstream:
|
||||
never compacted means everything is live.
|
||||
- ``_exec_recall`` passes its own workstream with a boundary read fresh at
|
||||
execution time, and labels own-conversation hits so the model knows it is
|
||||
re-reading its compacted past.
|
||||
- The exclusion composes with the #745 tenancy scope, and the resume nudge
|
||||
teaches the model the path exists.
|
||||
|
||||
Other workstreams are untouched — recall remains the cross-conversation
|
||||
search tool. The /history command deliberately has no exclusion: a human
|
||||
browsing history has no "context" to duplicate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from tests._session_helpers import make_session
|
||||
from turnstone.core.metacognition import NUDGE_COMPACTION_RESUME
|
||||
from turnstone.core.session import COMPACTION_SOURCE
|
||||
|
||||
NEEDLE = "quillfeather"
|
||||
|
||||
|
||||
def _fill(st, ws: str, owner: str = "u1") -> list[int]:
|
||||
"""Register ``ws`` and write four searchable rows; return their ids."""
|
||||
st.register_workstream(ws, user_id=owner, title="t", kind="interactive")
|
||||
return [st.save_message(ws, "user", f"{NEEDLE} row{i} in {ws}") for i in range(4)]
|
||||
|
||||
|
||||
def _mark(st, ws: str, watermark: int | None, content: str = "SUMMARY") -> int:
|
||||
"""Write a compaction marker with ``watermark`` (None = malformed/legacy meta)."""
|
||||
meta = json.dumps({"watermark": watermark}) if watermark is not None else None
|
||||
return st.save_message(ws, "assistant", content, source=COMPACTION_SOURCE, meta=meta)
|
||||
|
||||
|
||||
def _hits(st, **kwargs) -> set[str]:
|
||||
return {r[3] for r in st.search_history(NEEDLE, limit=50, **kwargs)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_compaction_checkpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetCompactionCheckpoint:
|
||||
def test_none_when_never_compacted(self, storage_backend):
|
||||
_fill(storage_backend, "ws1")
|
||||
assert storage_backend.get_compaction_checkpoint("ws1") is None
|
||||
|
||||
def test_reads_marker_watermark(self, storage_backend):
|
||||
st = storage_backend
|
||||
ids = _fill(st, "ws1")
|
||||
_mark(st, "ws1", ids[1])
|
||||
assert st.get_compaction_checkpoint("ws1") == ids[1]
|
||||
|
||||
def test_latest_marker_wins(self, storage_backend):
|
||||
st = storage_backend
|
||||
ids = _fill(st, "ws1")
|
||||
_mark(st, "ws1", ids[0])
|
||||
_mark(st, "ws1", ids[2])
|
||||
assert st.get_compaction_checkpoint("ws1") == ids[2]
|
||||
|
||||
def test_malformed_meta_reads_none(self, storage_backend):
|
||||
"""A legacy/corrupt marker must read as 'whole ws live' (exclude all),
|
||||
never as a garbage boundary."""
|
||||
st = storage_backend
|
||||
_fill(st, "ws1")
|
||||
_mark(st, "ws1", None)
|
||||
assert st.get_compaction_checkpoint("ws1") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_history live-context exclusion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLiveContextExclusion:
|
||||
def test_excludes_live_segment_keeps_compacted_past(self, storage_backend):
|
||||
st = storage_backend
|
||||
ids = _fill(st, "ws1") # rows 0..3
|
||||
boundary = ids[1] # rows 0-1 compacted away; 2-3 live
|
||||
found = _hits(st, exclude_ws_id="ws1", exclude_after=boundary)
|
||||
assert found == {f"{NEEDLE} row0 in ws1", f"{NEEDLE} row1 in ws1"}
|
||||
|
||||
def test_never_compacted_ws_fully_excluded(self, storage_backend):
|
||||
st = storage_backend
|
||||
_fill(st, "ws1")
|
||||
assert _hits(st, exclude_ws_id="ws1", exclude_after=None) == set()
|
||||
|
||||
def test_other_workstreams_unaffected(self, storage_backend):
|
||||
st = storage_backend
|
||||
_fill(st, "ws1")
|
||||
_fill(st, "ws2")
|
||||
found = _hits(st, exclude_ws_id="ws1", exclude_after=None)
|
||||
assert found == {f"{NEEDLE} row{i} in ws2" for i in range(4)}
|
||||
|
||||
def test_no_exclusion_without_ws(self, storage_backend):
|
||||
"""The /history command path: no exclude args → everything searchable."""
|
||||
st = storage_backend
|
||||
_fill(st, "ws1")
|
||||
assert len(_hits(st)) == 4
|
||||
|
||||
def test_composes_with_tenancy_scope(self, storage_backend):
|
||||
"""Exclusion and the #745 private-project predicate BOTH drop rows in
|
||||
one query: a mid-conversation boundary leaves ws_mine rows 2-3 live
|
||||
(excluded) and 0-1 compacted (kept), while the tenancy predicate
|
||||
hides dave's private-project row from carol — deleting either
|
||||
fragment fails this test."""
|
||||
st = storage_backend
|
||||
st.create_project("P", "P", owner_id="alice", visibility="private")
|
||||
ids = _fill(st, "ws_mine", owner="alice")
|
||||
st.register_workstream("ws_priv", user_id="dave", title="t", project_id="P")
|
||||
st.save_message("ws_priv", "user", f"{NEEDLE} private row")
|
||||
boundary = ids[1] # rows 0-1 compacted past; rows 2-3 live context
|
||||
_mark(st, "ws_mine", boundary)
|
||||
found = _hits(st, user_id="carol", exclude_ws_id="ws_mine", exclude_after=boundary)
|
||||
assert found == {f"{NEEDLE} row0 in ws_mine", f"{NEEDLE} row1 in ws_mine"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _exec_recall plumbing + labeling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRecallExecScope:
|
||||
def _run_recall(self, session, rows, monkeypatch, checkpoint=7):
|
||||
calls: dict = {}
|
||||
|
||||
def fake_search_history(query, limit=20, offset=0, **kwargs):
|
||||
calls.update(kwargs)
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr("turnstone.core.session.search_history", fake_search_history)
|
||||
monkeypatch.setattr(
|
||||
"turnstone.core.session.get_compaction_checkpoint", lambda ws: checkpoint
|
||||
)
|
||||
item = session._prepare_recall("c1", {"query": "x"})
|
||||
_, output = session._exec_recall(item)
|
||||
return calls, output
|
||||
|
||||
def test_passes_own_ws_and_fresh_boundary(self, monkeypatch):
|
||||
session = make_session(user_id="owner")
|
||||
session._ws_id = "ws-self"
|
||||
calls, _ = self._run_recall(session, [], monkeypatch, checkpoint=42)
|
||||
assert calls["exclude_ws_id"] == "ws-self"
|
||||
assert calls["exclude_after"] == 42
|
||||
|
||||
def test_no_exclusion_without_registered_ws(self, monkeypatch):
|
||||
session = make_session(user_id="owner")
|
||||
session._ws_id = ""
|
||||
calls, _ = self._run_recall(session, [], monkeypatch)
|
||||
assert calls["exclude_ws_id"] is None
|
||||
assert calls["exclude_after"] is None
|
||||
|
||||
def test_own_conversation_hits_are_labeled(self, monkeypatch):
|
||||
session = make_session(user_id="owner")
|
||||
session._ws_id = "ws-self"
|
||||
rows = [
|
||||
("2026-07-02T10:00:00", "ws-self", "user", "old detail", None),
|
||||
("2026-07-02T11:00:00", "ws-other", "user", "other detail", None),
|
||||
]
|
||||
_, output = self._run_recall(session, rows, monkeypatch)
|
||||
own_line = next(line for line in output.splitlines() if "old detail" in line)
|
||||
other_line = next(line for line in output.splitlines() if "other detail" in line)
|
||||
assert "(earlier in this conversation, compacted)" in own_line
|
||||
assert "(earlier in this conversation, compacted)" not in other_line
|
||||
|
||||
|
||||
def test_resume_nudge_teaches_recall():
|
||||
"""The model is told the summary is a digest and recall reaches the
|
||||
compacted portion — the pointer that makes the instrumented form usable."""
|
||||
assert "recall tool" in NUDGE_COMPACTION_RESUME
|
||||
assert "compacted portion" in NUDGE_COMPACTION_RESUME
|
||||
@@ -1511,3 +1511,41 @@ def test_link_url_with_ampersand_not_double_escaped() -> None:
|
||||
"Expected single & encoding for `&`; got:\n" + out
|
||||
)
|
||||
assert "&amp;" not in out
|
||||
|
||||
|
||||
def test_render_markdown_depth_capped_and_throw_safe() -> None:
|
||||
"""Perf-audit P0: ``renderMarkdown`` recurses for blockquote/callout
|
||||
bodies, and a few KB of nested ``"> "`` used to overflow the call stack
|
||||
mid-render. The exported wrapper depth-caps the recursion (bailing to
|
||||
escaped text) and keeps the ``_fnDepth`` accounting in a try/finally so a
|
||||
body throw can't strand it elevated (which froze ``_fnScopeId`` and
|
||||
collided footnote ids for every later message)."""
|
||||
body = _RENDERER_JS.read_text(encoding="utf-8")
|
||||
assert "var _MD_MAX_DEPTH" in body
|
||||
assert "_fnDepth >= _MD_MAX_DEPTH" in body
|
||||
wrapper = body.index("export function renderMarkdown(text)")
|
||||
seg = body[wrapper : body.index("function _renderMarkdownBody(text)")]
|
||||
assert "try {" in seg and "finally {" in seg and "_fnDepth--;" in seg, (
|
||||
"depth accounting must ride a try/finally in the wrapper"
|
||||
)
|
||||
|
||||
|
||||
def test_streaming_apply_marks_buffer_only_on_success() -> None:
|
||||
"""Perf-audit P0: ``_streamingRenderApply`` must set
|
||||
``el._lastRenderedBuffer`` only AFTER a successful render, with a
|
||||
plain-text fallback on throw. Marking before the render made an errored
|
||||
frame look done — the finalize short-circuit then pinned the broken DOM
|
||||
forever. The mermaid chain must also be rejection-proof (a sync throw in
|
||||
a settle handler used to leave every later diagram stuck at 'Loading
|
||||
diagram…')."""
|
||||
body = _RENDERER_JS.read_text(encoding="utf-8")
|
||||
apply_at = body.index("function _streamingRenderApply")
|
||||
seg = body[apply_at : apply_at + 2000]
|
||||
render_at = seg.index("renderMarkdown(buffer)")
|
||||
mark_at = seg.index("el._lastRenderedBuffer = buffer;")
|
||||
assert render_at < mark_at, "buffer must be marked rendered only on success"
|
||||
assert "el.textContent = buffer;" in seg
|
||||
chain_at = body.index("_mermaidRenderChain = _mermaidRenderChain")
|
||||
assert ".catch(function (e) {" in body[chain_at : chain_at + 3500], (
|
||||
"every mermaid chain link must settle back to fulfilled"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ for its L-shell dashboard, plus a regression guard for the single-kind
|
||||
:func:`turnstone.core.session_routes._collect_saved_rows`.
|
||||
|
||||
Storage is mocked (``list_workstreams_with_history`` is patched to
|
||||
return synthetic 15-tuples) — no real or dev database is touched. The
|
||||
return synthetic 17-tuples) — no real or dev database is touched. The
|
||||
request is a :class:`unittest.mock.MagicMock`, matching how the
|
||||
body-level coordinator endpoint tests build request stubs; the saved
|
||||
path only reads ``request`` to pass it to ``saved_loaded_lookup`` /
|
||||
@@ -41,7 +41,7 @@ pytestmark = pytest.mark.anyio
|
||||
# Column order from list_workstreams_with_history (keep in sync with the
|
||||
# storage SELECT): ws_id, alias, title, name, created, updated,
|
||||
# message_count, node_id, state, kind, model_alias, launch_skill,
|
||||
# child_count, context_tokens, context_window.
|
||||
# child_count, context_tokens, context_window, project_id, owner.
|
||||
def _row(
|
||||
ws_id: str,
|
||||
*,
|
||||
@@ -49,8 +49,10 @@ def _row(
|
||||
kind: str,
|
||||
state: str = "closed",
|
||||
name: str | None = None,
|
||||
project_id: str | None = None,
|
||||
owner: str | None = None,
|
||||
) -> tuple[Any, ...]:
|
||||
"""Build a synthetic storage row (15-tuple) for one workstream."""
|
||||
"""Build a synthetic storage row (17-tuple) for one workstream."""
|
||||
return (
|
||||
ws_id,
|
||||
None, # alias
|
||||
@@ -67,6 +69,8 @@ def _row(
|
||||
0, # child_count
|
||||
1000, # context_tokens
|
||||
4000, # context_window
|
||||
project_id, # project_id
|
||||
owner, # owner user_id
|
||||
)
|
||||
|
||||
|
||||
@@ -133,12 +137,16 @@ def _patch_storage(
|
||||
kind: Any = None,
|
||||
user_id: Any = None,
|
||||
state: Any = None,
|
||||
offset: int = 0,
|
||||
) -> list[tuple[Any, ...]]:
|
||||
calls.append({"kind": kind, "state": state, "user_id": user_id, "limit": limit})
|
||||
# Honour limit/offset like the real query — the collector pages
|
||||
# with OFFSET until it fills its visibility window, so a fake
|
||||
# that ignored them would return the same batch forever.
|
||||
if kind == WorkstreamKind.COORDINATOR:
|
||||
return coord_rows
|
||||
return coord_rows[offset : offset + limit]
|
||||
if kind == WorkstreamKind.INTERACTIVE:
|
||||
return interactive_rows
|
||||
return interactive_rows[offset : offset + limit]
|
||||
return []
|
||||
|
||||
# The handler imports the symbol from turnstone.core.memory at call
|
||||
@@ -343,6 +351,7 @@ async def test_single_kind_saved_unchanged(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
"child_count",
|
||||
"context_tokens",
|
||||
"context_ratio",
|
||||
"project_id",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tenancy scoping for conversation-history search (recall tool + /history).
|
||||
|
||||
``search_history`` / ``search_history_recent`` used to search every
|
||||
workstream's rows regardless of who asked — with private projects
|
||||
(migration 062) that is a cross-tenant read. The SQL predicate
|
||||
(``HISTORY_VISIBILITY_SCOPE_SQL``) mirrors ``WorkstreamProjectVisibility``
|
||||
(core.auth), THE statement of the tenancy rule: a row is hidden only when
|
||||
its workstream links to an EXISTING project whose visibility is private and
|
||||
the searcher is neither the workstream creator, the project owner, nor a
|
||||
member. Covered here:
|
||||
|
||||
- unscoped (``user_id=None``) stays tenant-wide — single-user CLI back-compat;
|
||||
- trusted-team default: no-project rows are visible across users;
|
||||
- private project: hidden from strangers; visible to the workstream creator,
|
||||
the project owner, and members — in both search and recent;
|
||||
- public project and dangling project link stay visible;
|
||||
- a NULL-creator workstream in a private project hides (COALESCE guard);
|
||||
- compaction markers stay excluded under scoping;
|
||||
- the sqlite LIKE fallback path applies the same predicate;
|
||||
- parity: SQL verdicts match ``ws_visible`` across the case matrix, so the
|
||||
two statements of the rule cannot drift silently;
|
||||
- session plumbing: ``_prepare_recall`` pins the scope at prepare time,
|
||||
``_exec_recall`` searches with the pinned identity and refuses to run
|
||||
unpinned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._session_helpers import make_session
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
|
||||
NEEDLE = "zebrafinch"
|
||||
|
||||
|
||||
def _ws(st, ws_id: str, owner: str | None, project_id: str | None = None) -> str:
|
||||
st.register_workstream(
|
||||
ws_id, user_id=owner, title="t", kind="interactive", project_id=project_id
|
||||
)
|
||||
st.save_message(ws_id, "user", f"{NEEDLE} in {ws_id}")
|
||||
return ws_id
|
||||
|
||||
|
||||
def _found(st, user_id: str | None) -> set[str]:
|
||||
return {r[1] for r in st.search_history(NEEDLE, limit=50, user_id=user_id)}
|
||||
|
||||
|
||||
def _recent(st, user_id: str | None) -> set[str]:
|
||||
return {r[1] for r in st.search_history_recent(limit=50, user_id=user_id)}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def world(storage_backend):
|
||||
"""One of each visibility case.
|
||||
|
||||
- ``ws_none`` — no project link (alice's)
|
||||
- ``ws_dangling`` — links a project that does not exist (bob's)
|
||||
- ``ws_public`` — public project, owned by alice
|
||||
- ``ws_priv_own`` — private project ``P`` (owner alice), ws created by alice
|
||||
- ``ws_priv_mem`` — private project ``P``, ws created by member bob
|
||||
- ``ws_priv_other``— private project ``Q`` (owner dave, no members)
|
||||
"""
|
||||
st = storage_backend
|
||||
st.create_project("pub", "Pub", owner_id="alice", visibility="public")
|
||||
st.create_project("P", "P", owner_id="alice", visibility="private")
|
||||
st.create_project("Q", "Q", owner_id="dave", visibility="private")
|
||||
st.add_project_member("P", "bob")
|
||||
_ws(st, "ws_none", "alice")
|
||||
_ws(st, "ws_dangling", "bob", project_id="ghost")
|
||||
_ws(st, "ws_public", "alice", project_id="pub")
|
||||
_ws(st, "ws_priv_own", "alice", project_id="P")
|
||||
_ws(st, "ws_priv_mem", "bob", project_id="P")
|
||||
_ws(st, "ws_priv_other", "dave", project_id="Q")
|
||||
return st
|
||||
|
||||
|
||||
ALL_WS = {"ws_none", "ws_dangling", "ws_public", "ws_priv_own", "ws_priv_mem", "ws_priv_other"}
|
||||
|
||||
|
||||
class TestSearchHistoryScope:
|
||||
def test_unscoped_stays_tenant_wide(self, world):
|
||||
"""CLI back-compat: ``user_id=None`` applies no filter."""
|
||||
assert _found(world, None) == ALL_WS
|
||||
assert _recent(world, None) == ALL_WS
|
||||
|
||||
def test_stranger_loses_only_private_rows(self, world):
|
||||
"""Trusted-team default: everything visible except other people's
|
||||
private-project workstreams."""
|
||||
expected = ALL_WS - {"ws_priv_own", "ws_priv_mem", "ws_priv_other"}
|
||||
assert _found(world, "carol") == expected
|
||||
assert _recent(world, "carol") == expected
|
||||
|
||||
def test_project_owner_sees_all_project_rows(self, world):
|
||||
"""alice owns P: sees bob's ws in P too; still not dave's Q."""
|
||||
assert _found(world, "alice") == ALL_WS - {"ws_priv_other"}
|
||||
|
||||
def test_member_sees_project_rows(self, world):
|
||||
"""bob is a member of P: sees alice's ws in P; still not Q."""
|
||||
assert _found(world, "bob") == ALL_WS - {"ws_priv_other"}
|
||||
|
||||
def test_ws_creator_sees_own_row_in_private_project(self, world):
|
||||
"""dave is neither owner nor member of P — but Q's rows are his."""
|
||||
assert "ws_priv_other" in _found(world, "dave")
|
||||
|
||||
def test_null_creator_private_ws_hides(self, storage_backend):
|
||||
"""A NULL-creator ws in a private project must hide, not leak: plain
|
||||
``<>`` goes NULL against a NULL creator and would drop the row from
|
||||
the hide-subquery (the COALESCE guard in the predicate)."""
|
||||
st = storage_backend
|
||||
st.create_project("P", "P", owner_id="alice", visibility="private")
|
||||
_ws(st, "ws_orphan_creator", None, project_id="P")
|
||||
assert _found(st, "carol") == set()
|
||||
assert _found(st, "alice") == {"ws_orphan_creator"} # project owner
|
||||
|
||||
def test_markers_stay_excluded_under_scope(self, world):
|
||||
"""The compaction-marker exclusion composes with the tenancy scope."""
|
||||
world.save_message(
|
||||
"ws_none",
|
||||
"assistant",
|
||||
f"{NEEDLE} SUMMARY",
|
||||
source="compaction",
|
||||
meta='{"watermark": 1}',
|
||||
)
|
||||
rows = world.search_history(NEEDLE, limit=50, user_id="alice")
|
||||
assert not any("SUMMARY" in (r[3] or "") for r in rows)
|
||||
|
||||
def test_like_fallback_applies_same_predicate(self, world):
|
||||
"""The sqlite non-FTS path must scope identically."""
|
||||
if not hasattr(world, "_fts5_available"):
|
||||
pytest.skip("LIKE fallback is sqlite-only")
|
||||
world._fts5_available = False
|
||||
expected = ALL_WS - {"ws_priv_own", "ws_priv_mem", "ws_priv_other"}
|
||||
assert _found(world, "carol") == expected
|
||||
|
||||
|
||||
class TestParityWithWsVisible:
|
||||
"""The SQL predicate and ``WorkstreamProjectVisibility`` are two
|
||||
statements of one rule; this pins them together so neither can drift
|
||||
without failing here."""
|
||||
|
||||
# (ws_id, creator, project_id) — mirrors the ``world`` fixture rows.
|
||||
MATRIX = [
|
||||
("ws_none", "alice", None),
|
||||
("ws_dangling", "bob", "ghost"),
|
||||
("ws_public", "alice", "pub"),
|
||||
("ws_priv_own", "alice", "P"),
|
||||
("ws_priv_mem", "bob", "P"),
|
||||
("ws_priv_other", "dave", "Q"),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("searcher", ["alice", "bob", "carol", "dave"])
|
||||
def test_sql_matches_python_predicate(self, world, searcher):
|
||||
vis = WorkstreamProjectVisibility(searcher, storage=world)
|
||||
expected = {
|
||||
ws_id
|
||||
for ws_id, creator, project_id in self.MATRIX
|
||||
if vis.ws_visible(project_id, ws_owner=creator or "")
|
||||
}
|
||||
assert _found(world, searcher) == expected
|
||||
assert _recent(world, searcher) == expected
|
||||
|
||||
|
||||
class TestRecallScopePlumbing:
|
||||
def _recorder(self, calls):
|
||||
def fake_search_history(query, limit=20, offset=0, *, user_id=None, **kwargs):
|
||||
calls.append(user_id)
|
||||
return []
|
||||
|
||||
return fake_search_history
|
||||
|
||||
def test_prepare_pins_owner_without_acting_user(self):
|
||||
session = make_session(user_id="owner")
|
||||
item = session._prepare_recall("c1", {"query": "x"})
|
||||
assert item["scope_user_id"] == "owner"
|
||||
|
||||
def test_prepare_pins_acting_user_over_owner(self):
|
||||
session = make_session(user_id="owner")
|
||||
session.bind_acting_user("driver")
|
||||
item = session._prepare_recall("c1", {"query": "x"})
|
||||
assert item["scope_user_id"] == "driver"
|
||||
|
||||
def test_prepare_pins_none_for_single_user_lanes(self):
|
||||
session = make_session() # user_id defaults to "" — CLI lane
|
||||
item = session._prepare_recall("c1", {"query": "x"})
|
||||
assert item["scope_user_id"] is None
|
||||
|
||||
def test_exec_searches_as_pinned_user(self, monkeypatch):
|
||||
calls: list[str | None] = []
|
||||
monkeypatch.setattr("turnstone.core.session.search_history", self._recorder(calls))
|
||||
session = make_session(user_id="owner")
|
||||
item = session._prepare_recall("c1", {"query": "x"})
|
||||
session._exec_recall(item)
|
||||
assert calls == ["owner"]
|
||||
|
||||
def test_exec_refuses_unpinned_item(self, monkeypatch):
|
||||
"""Fail loudly rather than fall back to a tenant-wide search."""
|
||||
calls: list[str | None] = []
|
||||
monkeypatch.setattr("turnstone.core.session.search_history", self._recorder(calls))
|
||||
session = make_session(user_id="owner")
|
||||
item = session._prepare_recall("c1", {"query": "x"})
|
||||
del item["scope_user_id"]
|
||||
with pytest.raises(KeyError):
|
||||
session._exec_recall(item)
|
||||
assert calls == []
|
||||
@@ -646,6 +646,7 @@ class TestListWorkstreamsTrustedTeamVisibility:
|
||||
"kind",
|
||||
"parent_ws_id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
}
|
||||
assert row["kind"] == "interactive"
|
||||
assert row["user_id"] == "user-shape"
|
||||
|
||||
+645
-14
@@ -13,6 +13,7 @@ import pytest
|
||||
|
||||
from turnstone.core.session import _IMAGE_EXTENSIONS, _IMAGE_SIZE_CAP, ChatSession
|
||||
from turnstone.core.trajectory import (
|
||||
Turn,
|
||||
dicts_from_turns,
|
||||
turn_from_dict,
|
||||
turn_to_dict,
|
||||
@@ -291,7 +292,7 @@ class TestTaskExec:
|
||||
|
||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
||||
session._exec_task(item)
|
||||
return captured["messages"][0]["content"]
|
||||
return captured["messages"][0].text
|
||||
|
||||
def test_known_skill_renders_into_system_message(self, tmp_db) -> None:
|
||||
"""Validated skill content (with template vars resolved) replaces
|
||||
@@ -1346,7 +1347,7 @@ class TestAgentOutputGuard:
|
||||
|
||||
with patch.object(session, "_prepare_tool", side_effect=fake_prepare):
|
||||
session._run_agent(
|
||||
[{"role": "user", "content": "test"}],
|
||||
[Turn.user("test")],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="test",
|
||||
)
|
||||
@@ -1409,7 +1410,7 @@ class TestAgentOutputGuard:
|
||||
|
||||
with patch.object(session, "_prepare_tool", side_effect=fake_prepare):
|
||||
session._run_agent(
|
||||
[{"role": "user", "content": "test"}],
|
||||
[Turn.user("test")],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="test",
|
||||
)
|
||||
@@ -1449,7 +1450,7 @@ class TestAgentOutputGuard:
|
||||
session.client.chat.completions.create = fake_create
|
||||
|
||||
result = session._run_agent(
|
||||
[{"role": "user", "content": "test"}],
|
||||
[Turn.user("test")],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="plan",
|
||||
)
|
||||
@@ -1487,7 +1488,7 @@ class TestAgentOutputGuard:
|
||||
|
||||
session.client.chat.completions.create = fake_create
|
||||
result = session._run_agent(
|
||||
[{"role": "user", "content": "test"}],
|
||||
[Turn.user("test")],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="task",
|
||||
)
|
||||
@@ -1522,8 +1523,8 @@ class TestAgentOutputGuard:
|
||||
session.client.chat.completions.create = fake_create
|
||||
result = session._run_agent(
|
||||
[
|
||||
{"role": "user", "content": "test"},
|
||||
{"role": "assistant", "content": prior},
|
||||
Turn.user("test"),
|
||||
Turn.assistant(prior),
|
||||
],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="plan",
|
||||
@@ -1536,6 +1537,59 @@ class TestAgentOutputGuard:
|
||||
assert args[1] == prior
|
||||
assert args[2] == "plan_agent_synthesis"
|
||||
|
||||
def test_non_overflow_terminal_error_salvages_partial_work(self):
|
||||
"""A NON-overflow terminal API error must still salvage the sub-agent's
|
||||
partial assistant work — regression guard: narrowing the salvage gate to
|
||||
overflow-only discarded a completed synthesis when the final call died on a
|
||||
persistent non-overflow error (e.g. a 5xx/timeout after retries)."""
|
||||
from turnstone.core.judge import JudgeConfig
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
|
||||
session = _make_session(judge_config=JudgeConfig(output_guard=True))
|
||||
session._provider = OpenAIChatCompletionsProvider()
|
||||
session._MAX_RETRIES = 0 # fail fast, no backoff
|
||||
|
||||
prior = "Substantial partial synthesis before the backend died."
|
||||
|
||||
with patch.object(
|
||||
session, "_evaluate_output", wraps=lambda cid, o, fn: (o, None)
|
||||
) as mock_eval:
|
||||
|
||||
def fake_create(**_kwargs):
|
||||
raise RuntimeError("upstream connect error or disconnect/reset (503)")
|
||||
|
||||
session.client.chat.completions.create = fake_create
|
||||
result = session._run_agent(
|
||||
[Turn.user("test"), Turn.assistant(prior)],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="task",
|
||||
)
|
||||
|
||||
assert result == prior # partial work salvaged, not discarded
|
||||
mock_eval.assert_called_once()
|
||||
assert mock_eval.call_args[0][1] == prior
|
||||
|
||||
def test_non_overflow_terminal_error_without_partial_work_reraises(self):
|
||||
"""With no partial assistant work to salvage, a non-overflow terminal error
|
||||
re-raises so the real failure surfaces to the coordinator rather than being
|
||||
masked as an empty success."""
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
|
||||
session = _make_session()
|
||||
session._provider = OpenAIChatCompletionsProvider()
|
||||
session._MAX_RETRIES = 0
|
||||
|
||||
def fake_create(**_kwargs):
|
||||
raise RuntimeError("upstream connect error or disconnect/reset (503)")
|
||||
|
||||
session.client.chat.completions.create = fake_create
|
||||
with pytest.raises(RuntimeError, match="503"):
|
||||
session._run_agent(
|
||||
[Turn.user("test")],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="task",
|
||||
)
|
||||
|
||||
def test_turn_limit_forced_synthesis_is_guarded(self):
|
||||
"""When max_tool_turns is exhausted, the forced synthesis call's
|
||||
content flows through the guard."""
|
||||
@@ -1587,7 +1641,7 @@ class TestAgentOutputGuard:
|
||||
|
||||
with patch.object(session, "_prepare_tool", side_effect=fake_prepare):
|
||||
result = session._run_agent(
|
||||
[{"role": "user", "content": "test"}],
|
||||
[Turn.user("test")],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="task",
|
||||
)
|
||||
@@ -1601,6 +1655,470 @@ class TestAgentOutputGuard:
|
||||
assert synth_args[2] == "task_agent_synthesis"
|
||||
|
||||
|
||||
class TestAgentChildRegistration:
|
||||
"""_run_agent registers each sub-tool under the task's parent_call_id so the
|
||||
UI can nest the step (the producer side of the SessionUIBase tagging)."""
|
||||
|
||||
def test_sub_tool_registered_under_parent(self):
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
|
||||
session = _make_session()
|
||||
session._provider = OpenAIChatCompletionsProvider()
|
||||
session.ui.note_agent_child = MagicMock()
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def fake_create(**_kwargs):
|
||||
call_count[0] += 1
|
||||
resp = MagicMock()
|
||||
choice = MagicMock()
|
||||
if call_count[0] == 1:
|
||||
choice.finish_reason = "tool_calls"
|
||||
tc = MagicMock()
|
||||
tc.id = "call_1"
|
||||
tc.function.name = "read_file"
|
||||
tc.function.arguments = '{"path": "/tmp/x"}'
|
||||
choice.message.tool_calls = [tc]
|
||||
choice.message.content = None
|
||||
else:
|
||||
choice.finish_reason = "stop"
|
||||
choice.message.tool_calls = None
|
||||
choice.message.content = "done"
|
||||
resp.choices = [choice]
|
||||
resp.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
|
||||
return resp
|
||||
|
||||
session.client.chat.completions.create = fake_create
|
||||
|
||||
def fake_prepare(tc_dict, **_kwargs):
|
||||
return {
|
||||
"call_id": tc_dict["id"],
|
||||
"func_name": "read_file",
|
||||
"needs_approval": False,
|
||||
"execute": lambda p: ("call_1", "contents"),
|
||||
}
|
||||
|
||||
with patch.object(session, "_prepare_tool", side_effect=fake_prepare):
|
||||
session._run_agent(
|
||||
[Turn.user("x")],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="task",
|
||||
parent_call_id="task-1",
|
||||
)
|
||||
|
||||
# Sub-agent tool ids are namespaced by the parent so the UI registry
|
||||
# can't collide across concurrent task agents (local sequential ids).
|
||||
session.ui.note_agent_child.assert_called_once_with("task-1::call_1", "task-1")
|
||||
|
||||
|
||||
class TestRunAgentDenialMessage:
|
||||
"""A denied sub-tool must surface the SPECIFIC denial reason that
|
||||
``approve_tools`` already stamped (operator feedback / matched policy),
|
||||
not a flat "Denied by user" — so the sub-agent can adapt. The pre-fix
|
||||
code clobbered ``denial_msg`` unconditionally and dropped the feedback
|
||||
returned as ``approve_tools``'s second value."""
|
||||
|
||||
def _run_with_denial(self, approve_side_effect):
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
from turnstone.core.trajectory import Turn
|
||||
|
||||
session = _make_session()
|
||||
session._provider = OpenAIChatCompletionsProvider()
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def fake_create(**_kwargs):
|
||||
call_count[0] += 1
|
||||
resp = MagicMock()
|
||||
choice = MagicMock()
|
||||
if call_count[0] == 1:
|
||||
choice.finish_reason = "tool_calls"
|
||||
tc = MagicMock()
|
||||
tc.id = "call_1"
|
||||
tc.function.name = "notify"
|
||||
tc.function.arguments = '{"message": "hi"}'
|
||||
choice.message.tool_calls = [tc]
|
||||
choice.message.content = None
|
||||
else:
|
||||
choice.finish_reason = "stop"
|
||||
choice.message.tool_calls = None
|
||||
choice.message.content = "done"
|
||||
resp.choices = [choice]
|
||||
resp.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
|
||||
return resp
|
||||
|
||||
session.client.chat.completions.create = fake_create
|
||||
# approve_tools is the real two-phase gate: on denial it stamps a
|
||||
# specific denial_msg on the item AND returns the reason as its 2nd
|
||||
# value. The sub-agent must honour both, not overwrite them.
|
||||
session.ui.approve_tools = MagicMock(side_effect=approve_side_effect)
|
||||
|
||||
def fake_prepare(tc_dict, **_kwargs):
|
||||
return {
|
||||
"call_id": tc_dict["id"],
|
||||
"func_name": "notify",
|
||||
"needs_approval": True,
|
||||
# Must NOT run — a denied tool never executes.
|
||||
"execute": lambda p: (p["call_id"], "EXECUTED — should not happen"),
|
||||
}
|
||||
|
||||
agent_turns: list[Turn] = [Turn.user("x")]
|
||||
with patch.object(session, "_prepare_tool", side_effect=fake_prepare):
|
||||
session._run_agent(
|
||||
agent_turns,
|
||||
tools=[{"type": "function", "function": {"name": "notify"}}],
|
||||
auto_tools=set(), # nothing auto -> notify routes through approval
|
||||
label="task",
|
||||
parent_call_id="task-1",
|
||||
)
|
||||
tool_turns = [t for t in agent_turns if t.role.value == "tool"]
|
||||
assert tool_turns, "expected a tool turn for the denied sub-tool"
|
||||
return tool_turns[-1].text
|
||||
|
||||
def test_human_feedback_preserved(self):
|
||||
def approve(items):
|
||||
items[0]["denied"] = True
|
||||
items[0]["denial_msg"] = "Denied by user: use /tmp instead"
|
||||
return False, "use /tmp instead"
|
||||
|
||||
text = self._run_with_denial(approve)
|
||||
assert text == "Denied by user: use /tmp instead"
|
||||
|
||||
def test_policy_reason_preserved(self):
|
||||
def approve(items):
|
||||
items[0]["denied"] = True
|
||||
items[0]["denial_msg"] = "Blocked by tool policy (pattern match for 'notify')"
|
||||
return False, "Blocked by tool policy"
|
||||
|
||||
text = self._run_with_denial(approve)
|
||||
assert text == "Blocked by tool policy (pattern match for 'notify')"
|
||||
|
||||
def test_default_when_gate_sets_nothing(self):
|
||||
# Defensive: a not-approved result that left no denial_msg still yields
|
||||
# a sensible default rather than executing the tool.
|
||||
def approve(items):
|
||||
return False, None
|
||||
|
||||
text = self._run_with_denial(approve)
|
||||
assert text == "Denied by user"
|
||||
|
||||
def test_cli_policy_block_error_field_preserved(self):
|
||||
# The CLI gate records a policy block in ``error`` (not ``denial_msg``)
|
||||
# and returns approved=True; the specific reason must still reach the
|
||||
# sub-agent rather than collapsing to a flat "Denied by user".
|
||||
def approve(items):
|
||||
items[0]["denied"] = True
|
||||
items[0]["error"] = "Blocked by tool policy ('notify')"
|
||||
return True, None
|
||||
|
||||
text = self._run_with_denial(approve)
|
||||
assert text == "Blocked by tool policy ('notify')"
|
||||
|
||||
|
||||
class TestProjectAgentSteps:
|
||||
"""``_project_agent_steps`` projects a finished sub-agent's trajectory into
|
||||
recall step items for the task card — one per tool call, matched to its
|
||||
result by call_id, landmine-safe on a multimodal result."""
|
||||
|
||||
def test_calls_matched_to_results_in_order(self):
|
||||
from turnstone.core.trajectory import ToolCall, Turn
|
||||
|
||||
turns = [
|
||||
Turn.system("sys"),
|
||||
Turn.user("go"),
|
||||
Turn.assistant(
|
||||
tool_calls=(ToolCall(id="c1", name="search", arguments='{"query":"x"}'),)
|
||||
),
|
||||
Turn.tool("c1", "12 matches"),
|
||||
Turn.assistant(
|
||||
tool_calls=(ToolCall(id="c2", name="bash", arguments='{"command":"ls"}'),)
|
||||
),
|
||||
Turn.tool("c2", "boom", is_error=True),
|
||||
]
|
||||
steps = ChatSession._project_agent_steps(turns)
|
||||
assert [s["id"] for s in steps] == ["c1", "c2"]
|
||||
assert steps[0] == {
|
||||
"id": "c1",
|
||||
"name": "search",
|
||||
"arguments": '{"query":"x"}',
|
||||
"output": "12 matches",
|
||||
"is_error": False,
|
||||
}
|
||||
assert steps[1]["is_error"] is True
|
||||
assert steps[1]["output"] == "boom"
|
||||
|
||||
def test_multimodal_result_placeholdered_not_crashed(self):
|
||||
# A vision tool result is a list[dict] mis-stored as TextBlock.text; the
|
||||
# projection must NOT call Turn.text (would TypeError) — it reads the
|
||||
# payload directly and placeholders a non-str so /history stays text-only.
|
||||
from turnstone.core.trajectory import ToolCall, Turn
|
||||
|
||||
turns = [
|
||||
Turn.assistant(
|
||||
tool_calls=(ToolCall(id="c1", name="read_file", arguments='{"path":"a.png"}'),)
|
||||
),
|
||||
Turn.tool("c1", [{"type": "image_url"}]),
|
||||
]
|
||||
steps = ChatSession._project_agent_steps(turns)
|
||||
assert steps[0]["output"] == "[non-text result]"
|
||||
|
||||
def test_output_capped(self):
|
||||
from turnstone.core.session import _AGENT_STEP_OUTPUT_CAP
|
||||
from turnstone.core.trajectory import ToolCall, Turn
|
||||
|
||||
big = "a" * (_AGENT_STEP_OUTPUT_CAP + 500)
|
||||
turns = [
|
||||
Turn.assistant(tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),)),
|
||||
Turn.tool("c1", big),
|
||||
]
|
||||
steps = ChatSession._project_agent_steps(turns)
|
||||
assert len(steps[0]["output"]) < len(big)
|
||||
assert "truncated from 2500 chars" in steps[0]["output"]
|
||||
|
||||
def test_unanswered_call_has_empty_output(self):
|
||||
# A tool call with no matching result (cancelled mid-flight) recalls
|
||||
# honestly as empty, not dropped.
|
||||
from turnstone.core.trajectory import ToolCall, Turn
|
||||
|
||||
turns = [Turn.assistant(tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),))]
|
||||
steps = ChatSession._project_agent_steps(turns)
|
||||
assert steps == [
|
||||
{"id": "c1", "name": "bash", "arguments": "{}", "output": "", "is_error": False}
|
||||
]
|
||||
|
||||
def test_colliding_ids_paired_fifo_not_last_wins(self):
|
||||
# A local provider reuses id "call_0" across turns; FIFO pairing gives
|
||||
# each call its OWN result, not last-wins (which would show out-B twice).
|
||||
from turnstone.core.trajectory import ToolCall, Turn
|
||||
|
||||
turns = [
|
||||
Turn.assistant(
|
||||
tool_calls=(ToolCall(id="call_0", name="bash", arguments='{"command":"a"}'),)
|
||||
),
|
||||
Turn.tool("call_0", "out-A"),
|
||||
Turn.assistant(
|
||||
tool_calls=(ToolCall(id="call_0", name="bash", arguments='{"command":"b"}'),)
|
||||
),
|
||||
Turn.tool("call_0", "out-B"),
|
||||
]
|
||||
steps = ChatSession._project_agent_steps(turns)
|
||||
assert [s["output"] for s in steps] == ["out-A", "out-B"]
|
||||
|
||||
def test_step_count_capped_with_honest_marker(self):
|
||||
from turnstone.core.session import _AGENT_STEP_COUNT_CAP
|
||||
from turnstone.core.trajectory import ToolCall, Turn
|
||||
|
||||
turns = []
|
||||
for i in range(_AGENT_STEP_COUNT_CAP + 5):
|
||||
turns.append(
|
||||
Turn.assistant(tool_calls=(ToolCall(id=f"c{i}", name="bash", arguments="{}"),))
|
||||
)
|
||||
turns.append(Turn.tool(f"c{i}", f"out{i}"))
|
||||
steps = ChatSession._project_agent_steps(turns)
|
||||
# Capped + one honest LEADING marker, keeping the most RECENT steps (the
|
||||
# tail) — not the earliest — and naming how many earlier ones fell out.
|
||||
assert len(steps) == _AGENT_STEP_COUNT_CAP + 1
|
||||
assert steps[0]["name"] == "…"
|
||||
assert "5 earlier steps not retained" in steps[0]["output"]
|
||||
# c0..c4 dropped; c5 is the first retained, the newest call is last.
|
||||
assert steps[1]["id"] == "c5"
|
||||
assert steps[-1]["id"] == f"c{_AGENT_STEP_COUNT_CAP + 4}"
|
||||
|
||||
|
||||
class TestAgentTrajectoryStashWiring:
|
||||
"""``_stash_agent_trajectory`` projects + forwards to the UI, getattr-guarded."""
|
||||
|
||||
def test_projects_and_forwards(self):
|
||||
from turnstone.core.trajectory import ToolCall, Turn
|
||||
|
||||
session = _make_session()
|
||||
session.ui = MagicMock()
|
||||
turns = [
|
||||
Turn.assistant(tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),)),
|
||||
Turn.tool("c1", "ok"),
|
||||
]
|
||||
session._stash_agent_trajectory("task1", turns)
|
||||
session.ui.stash_agent_trajectory.assert_called_once()
|
||||
cid, steps = session.ui.stash_agent_trajectory.call_args[0]
|
||||
assert cid == "task1"
|
||||
assert steps == [
|
||||
{"id": "c1", "name": "bash", "arguments": "{}", "output": "ok", "is_error": False}
|
||||
]
|
||||
|
||||
def test_noop_without_call_id(self):
|
||||
session = _make_session()
|
||||
session.ui = MagicMock()
|
||||
session._stash_agent_trajectory(None, [])
|
||||
session.ui.stash_agent_trajectory.assert_not_called()
|
||||
|
||||
def test_noop_on_ui_without_support(self):
|
||||
# NullUI has no stash_agent_trajectory → getattr None → no-op, no raise.
|
||||
_make_session()._stash_agent_trajectory("task1", [])
|
||||
|
||||
|
||||
class TestReadFilesIsolation:
|
||||
"""A task agent's file-read tracking is isolated from the main session and
|
||||
its pool siblings via ``_active_read_files`` so the blind-overwrite guard
|
||||
can't be cross-contaminated (a sibling's read suppressing another's guard)."""
|
||||
|
||||
def test_defaults_to_main_set(self):
|
||||
session = _make_session()
|
||||
assert session._current_read_files is session._read_files
|
||||
|
||||
def test_active_contextvar_overrides_then_restores(self):
|
||||
from turnstone.core.session import _active_read_files
|
||||
|
||||
session = _make_session()
|
||||
sub: set[str] = set()
|
||||
token = _active_read_files.set(sub)
|
||||
try:
|
||||
assert session._current_read_files is sub
|
||||
finally:
|
||||
_active_read_files.reset(token)
|
||||
assert session._current_read_files is session._read_files
|
||||
|
||||
def test_empty_active_set_is_used_not_main(self):
|
||||
# The resolver guards on `is not None`, not truthiness — an EMPTY
|
||||
# per-agent set must be used, NOT fall through to the main set, or a
|
||||
# fresh agent would inherit the main session's reads and mis-suppress
|
||||
# its own blind-overwrite guard.
|
||||
from turnstone.core.session import _active_read_files
|
||||
|
||||
session = _make_session()
|
||||
session._read_files.add("/main/file")
|
||||
token = _active_read_files.set(set())
|
||||
try:
|
||||
assert session._current_read_files == set()
|
||||
finally:
|
||||
_active_read_files.reset(token)
|
||||
|
||||
def test_exec_task_copies_parent_reads_and_merges_back(self):
|
||||
# Drive the REAL _exec_task wiring (not a hand-rolled contextvar dance):
|
||||
# it copies the parent's reads into an INDEPENDENT per-agent set (so the
|
||||
# agent can edit a file the parent read for it, without leaking mid-run
|
||||
# to a sibling) and merges the agent's own reads back on completion.
|
||||
session = _make_session()
|
||||
session._agent_system_messages = []
|
||||
session._task_tools = []
|
||||
session._read_files.add("/parent/read")
|
||||
seen = {}
|
||||
|
||||
def fake_run_agent(agent_turns, **_kwargs):
|
||||
seen["sees_parent"] = "/parent/read" in session._current_read_files
|
||||
session._current_read_files.add("/child/read")
|
||||
seen["child_isolated"] = "/child/read" not in session._read_files
|
||||
return "done"
|
||||
|
||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
||||
cid, out = session._exec_task({"call_id": "t1", "prompt": "go"})
|
||||
|
||||
assert (cid, out) == ("t1", "done")
|
||||
assert seen["sees_parent"] is True # copy-on-spawn: inherits parent's reads
|
||||
assert seen["child_isolated"] is True # independent set mid-run (no leak)
|
||||
assert "/child/read" in session._read_files # merged back on completion
|
||||
assert session._current_read_files is session._read_files # contextvar reset
|
||||
|
||||
|
||||
class TestSubAgentErrorRecall:
|
||||
"""_run_agent stamps is_error on a sub-tool's Turn from the authoritative
|
||||
_tool_error_flags, so a failed sub-tool recalls styled as an error rather
|
||||
than a green 'done' step (the most serious review finding)."""
|
||||
|
||||
def test_errored_sub_tool_turn_marked_is_error(self):
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
from turnstone.core.trajectory import Role
|
||||
|
||||
session = _make_session()
|
||||
session._provider = OpenAIChatCompletionsProvider()
|
||||
calls = [0]
|
||||
|
||||
def fake_create(**_kwargs):
|
||||
calls[0] += 1
|
||||
resp = MagicMock()
|
||||
choice = MagicMock()
|
||||
if calls[0] == 1:
|
||||
choice.finish_reason = "tool_calls"
|
||||
tc = MagicMock()
|
||||
tc.id = "call_1"
|
||||
tc.function.name = "bash"
|
||||
tc.function.arguments = '{"command":"false"}'
|
||||
choice.message.tool_calls = [tc]
|
||||
choice.message.content = None
|
||||
else:
|
||||
choice.finish_reason = "stop"
|
||||
choice.message.tool_calls = None
|
||||
choice.message.content = "done"
|
||||
resp.choices = [choice]
|
||||
resp.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
|
||||
return resp
|
||||
|
||||
session.client.chat.completions.create = fake_create
|
||||
|
||||
def fake_prepare(tc_dict, **_kwargs):
|
||||
cid = tc_dict["id"]
|
||||
|
||||
def _exec(p):
|
||||
# Simulate an errored tool: the real exec records is_error via
|
||||
# _report_tool_result, which sets _tool_error_flags.
|
||||
session._tool_error_flags[p["call_id"]] = True
|
||||
return cid, "boom"
|
||||
|
||||
return {
|
||||
"call_id": cid,
|
||||
"func_name": "bash",
|
||||
"needs_approval": False,
|
||||
"execute": _exec,
|
||||
}
|
||||
|
||||
turns = [Turn.user("run it")]
|
||||
with patch.object(session, "_prepare_tool", side_effect=fake_prepare):
|
||||
session._run_agent(
|
||||
turns,
|
||||
tools=[{"type": "function", "function": {"name": "bash"}}],
|
||||
label="task",
|
||||
auto_tools={"bash"},
|
||||
parent_call_id="t1",
|
||||
)
|
||||
|
||||
tool_turns = [t for t in turns if t.role is Role.TOOL]
|
||||
assert tool_turns, "expected a tool result turn"
|
||||
assert tool_turns[-1].is_error is True
|
||||
# And it carries through the projection to the recalled step.
|
||||
assert ChatSession._project_agent_steps(turns)[-1]["is_error"] is True
|
||||
|
||||
|
||||
class TestExecTaskReporting:
|
||||
"""_exec_task self-reports the task_agent's OWN result — the live card's
|
||||
only completion signal (the parent loop reports error/denied results
|
||||
centrally but relies on each tool self-reporting its success result)."""
|
||||
|
||||
def _bare_session(self):
|
||||
session = _make_session()
|
||||
session._agent_system_messages = []
|
||||
session._task_tools = []
|
||||
return session
|
||||
|
||||
def test_success_reports_result(self):
|
||||
session = self._bare_session()
|
||||
with (
|
||||
patch.object(session, "_run_agent", return_value="the synthesis"),
|
||||
patch.object(session, "_report_tool_result") as rpt,
|
||||
):
|
||||
cid, out = session._exec_task({"call_id": "t1", "prompt": "go"})
|
||||
assert (cid, out) == ("t1", "the synthesis")
|
||||
rpt.assert_called_once_with("t1", "task_agent", "the synthesis")
|
||||
|
||||
def test_error_reports_is_error(self):
|
||||
session = self._bare_session()
|
||||
with (
|
||||
patch.object(session, "_run_agent", side_effect=RuntimeError("boom")),
|
||||
patch.object(session, "_report_tool_result") as rpt,
|
||||
):
|
||||
cid, out = session._exec_task({"call_id": "t1", "prompt": "go"})
|
||||
assert out == "Task error: boom"
|
||||
rpt.assert_called_once_with("t1", "task_agent", "Task error: boom", is_error=True)
|
||||
|
||||
|
||||
class TestEvaluateOutputLLMStage:
|
||||
"""End-to-end coverage of _evaluate_output with the LLM judge stage."""
|
||||
|
||||
@@ -3409,19 +3927,132 @@ class TestMemoryAccessTouch:
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
def test_save_action_does_not_touch_access_count(self, tmp_db):
|
||||
"""The save action handler itself must not bump ``access_count`` —
|
||||
that counter is read traffic only. (The recompose a save triggers
|
||||
may surface the row via the composition path; that is exercised by
|
||||
the composition tests. Suppressed here to isolate the handler.)"""
|
||||
"""The save action handler must not bump ``access_count`` — that counter
|
||||
is read traffic only, and save no longer recomposes the system prefix
|
||||
(see ``_exec_memory``), so the saved row is never surfaced as an injected
|
||||
memory by the handler. Composition-path touches are exercised by the
|
||||
``test_composition_*`` tests."""
|
||||
session = self._empty_session()
|
||||
item = session._prepare_memory(
|
||||
"call_1",
|
||||
{"action": "save", "name": "kafka_runbook", "content": "x", "scope": "global"},
|
||||
)
|
||||
with patch.object(session, "_init_system_messages"):
|
||||
session._exec_memory(item)
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
def test_save_through_exec_does_not_recompose_prefix(self, tmp_db):
|
||||
"""End-to-end through ``_exec_memory``: a memory(save) must NOT rebuild
|
||||
the system prefix -- injected memories ride in the cached system block,
|
||||
so re-initing on every write would bust the prompt cache. The write
|
||||
still (a) invalidates the per-turn search cache so an in-turn
|
||||
memory(search) sees the new row, and (b) folds into the prefix at the
|
||||
next natural recompose. Exercises the real call chain (no patching of
|
||||
``_init_system_messages``), which the other memory tests stub out."""
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._compose_turn(session, "restart kafka broker pods status")
|
||||
before = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
assert '<memory name="kafka_runbook"' in before # composition sanity
|
||||
|
||||
# Prime the per-turn search cache with a probe that excludes the
|
||||
# not-yet-saved row, so a stale cache would be observable below.
|
||||
probe = "scale the broker pods cluster"
|
||||
assert "kafka_scaling" not in {m["name"] for m in session._search_visible_memories(probe)}
|
||||
|
||||
item = session._prepare_memory(
|
||||
"call_1",
|
||||
{
|
||||
"action": "save",
|
||||
"name": "kafka_scaling",
|
||||
"content": "restart kafka and scale the broker pods cluster",
|
||||
"scope": "global",
|
||||
},
|
||||
)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
|
||||
# 1. Prefix byte-for-byte unchanged -> no prompt-cache bust.
|
||||
after = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
assert after == before
|
||||
assert '<memory name="kafka_scaling"' not in after
|
||||
|
||||
# 2. The save invalidated the search cache: the SAME probe now returns
|
||||
# the new row (a stale cache would still omit it).
|
||||
assert "kafka_scaling" in {m["name"] for m in session._search_visible_memories(probe)}
|
||||
|
||||
# 3. The next natural recompose folds the new memory into the prefix.
|
||||
self._compose_turn(session, "how do I scale the kafka broker pods cluster")
|
||||
recomposed = "\n".join(
|
||||
m["content"] for m in session.system_messages if m["role"] == "system"
|
||||
)
|
||||
assert '<memory name="kafka_scaling"' in recomposed
|
||||
|
||||
def test_save_through_tool_preserves_omitted_overwrites_explicit(self, tmp_db):
|
||||
"""The None-sentinel flows through _prepare_memory -> _exec_memory: a
|
||||
content-only re-save keeps the stored type/description, while an
|
||||
explicit field overwrites it. Guards the _prepare_memory omit->None
|
||||
logic that the storage-level tests don't exercise."""
|
||||
from turnstone.core.memory import get_structured_memory_by_name
|
||||
|
||||
session = self._empty_session()
|
||||
item = session._prepare_memory(
|
||||
"c1",
|
||||
{
|
||||
"action": "save",
|
||||
"name": "digest",
|
||||
"content": "v1",
|
||||
"type": "reference",
|
||||
"description": "daily digest",
|
||||
"scope": "global",
|
||||
},
|
||||
)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
|
||||
# Content-only re-save (omits type/description) -> both preserved.
|
||||
item2 = session._prepare_memory(
|
||||
"c2", {"action": "save", "name": "digest", "content": "v2", "scope": "global"}
|
||||
)
|
||||
session._exec_memory(item2)
|
||||
mem = get_structured_memory_by_name("digest", "global", "")
|
||||
assert mem is not None
|
||||
assert mem["content"] == "v2"
|
||||
assert mem["type"] == "reference"
|
||||
assert mem["description"] == "daily digest"
|
||||
|
||||
# An invalid/typo'd type is treated as unset -> stored type preserved,
|
||||
# not silently downgraded to "general".
|
||||
item_bad = session._prepare_memory(
|
||||
"c2b",
|
||||
{
|
||||
"action": "save",
|
||||
"name": "digest",
|
||||
"content": "v2b",
|
||||
"type": "nonsense",
|
||||
"scope": "global",
|
||||
},
|
||||
)
|
||||
session._exec_memory(item_bad)
|
||||
mem = get_structured_memory_by_name("digest", "global", "")
|
||||
assert mem is not None
|
||||
assert mem["type"] == "reference" # invalid type ignored, not downgraded
|
||||
|
||||
# An explicit field -> overwrites (the behaviour the None-sentinel enables).
|
||||
item3 = session._prepare_memory(
|
||||
"c3",
|
||||
{
|
||||
"action": "save",
|
||||
"name": "digest",
|
||||
"content": "v3",
|
||||
"type": "general",
|
||||
"scope": "global",
|
||||
},
|
||||
)
|
||||
session._exec_memory(item3)
|
||||
mem = get_structured_memory_by_name("digest", "global", "")
|
||||
assert mem is not None
|
||||
assert mem["type"] == "general"
|
||||
|
||||
|
||||
class TestMetacognitiveBuffers:
|
||||
"""Nudges drain through advisory channels, not the system message."""
|
||||
|
||||
@@ -157,6 +157,19 @@ def test_rate_limit_message():
|
||||
assert "limit exceeded" in msg
|
||||
|
||||
|
||||
def test_rate_limit_with_overflow_phrasing_is_not_mislabeled_overflow():
|
||||
"""A recognized RateLimitError whose quota text happens to contain a
|
||||
context-overflow phrase must still render as rate-limited — the text-based
|
||||
overflow branch is gated on 'not a known class', so it can't hijack a
|
||||
recognized error and mark a transient 429 as a hard 'Context window exceeded'."""
|
||||
msg = _format(
|
||||
_stub(), RateLimitError("exceeds the maximum number of tokens allowed per minute")
|
||||
)
|
||||
assert msg is not None
|
||||
assert "Backend rate-limited" in msg
|
||||
assert "Context window exceeded" not in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fall-through + degradation behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,8 @@ import threading
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.session_ui_base import SessionUIBase
|
||||
|
||||
|
||||
@@ -2089,3 +2091,175 @@ def test_tool_pending_precedes_smart_approval_gate() -> None:
|
||||
|
||||
assert approved is True
|
||||
assert captured and captured[0] == "tool_pending", captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-agent step tagging (task_agent child events nest under the parent card)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAgentChildTagging:
|
||||
"""``note_agent_child`` makes ``_enqueue`` stamp ``parent_call_id`` on a
|
||||
sub-tool's events so the UI can nest a task agent's steps under its card.
|
||||
Keyed on the immutable child call_id (correct under the parent's parallel
|
||||
tool pool); cleared when the task agent finishes."""
|
||||
|
||||
def test_registered_child_event_is_stamped(self) -> None:
|
||||
ui = _make_ui()
|
||||
lq = ui._register_listener()
|
||||
ui.note_agent_child("child-1", "task-A")
|
||||
ui._enqueue({"type": "tool_result", "call_id": "child-1", "name": "bash", "output": "ok"})
|
||||
assert lq.get_nowait()["parent_call_id"] == "task-A"
|
||||
|
||||
def test_unregistered_call_id_is_not_stamped(self) -> None:
|
||||
ui = _make_ui()
|
||||
lq = ui._register_listener()
|
||||
ui.note_agent_child("child-1", "task-A")
|
||||
ui._enqueue({"type": "tool_result", "call_id": "other", "name": "x", "output": "y"})
|
||||
assert "parent_call_id" not in lq.get_nowait()
|
||||
|
||||
def test_no_registry_no_stamp(self) -> None:
|
||||
"""Empty registry short-circuits — events pass through untouched."""
|
||||
ui = _make_ui()
|
||||
lq = ui._register_listener()
|
||||
ui._enqueue({"type": "tool_result", "call_id": "child-1", "name": "x", "output": "y"})
|
||||
assert "parent_call_id" not in lq.get_nowait()
|
||||
|
||||
def test_items_payload_is_stamped_per_entry(self) -> None:
|
||||
"""approve_request / tool_pending carry an ``items`` list; each child
|
||||
entry is tagged independently, leaving non-child entries alone."""
|
||||
ui = _make_ui()
|
||||
lq = ui._register_listener()
|
||||
ui.note_agent_child("child-1", "task-A")
|
||||
ui._enqueue(
|
||||
{
|
||||
"type": "tool_pending",
|
||||
"items": [
|
||||
{"call_id": "child-1", "func_name": "bash"},
|
||||
{"call_id": "top-level", "func_name": "search"},
|
||||
],
|
||||
}
|
||||
)
|
||||
items = lq.get_nowait()["items"]
|
||||
assert items[0]["parent_call_id"] == "task-A"
|
||||
assert "parent_call_id" not in items[1]
|
||||
|
||||
def test_clear_agent_children_stops_stamping(self) -> None:
|
||||
ui = _make_ui()
|
||||
lq = ui._register_listener()
|
||||
ui.note_agent_child("child-1", "task-A")
|
||||
ui.clear_agent_children("task-A")
|
||||
ui._enqueue({"type": "tool_result", "call_id": "child-1", "name": "x", "output": "y"})
|
||||
assert "parent_call_id" not in lq.get_nowait()
|
||||
|
||||
def test_clear_is_scoped_to_one_parent(self) -> None:
|
||||
"""Two task agents in flight: clearing one leaves the other's children
|
||||
tagged — the parallel-pool invariant."""
|
||||
ui = _make_ui()
|
||||
lq = ui._register_listener()
|
||||
ui.note_agent_child("child-A", "task-A")
|
||||
ui.note_agent_child("child-B", "task-B")
|
||||
ui.clear_agent_children("task-A")
|
||||
ui._enqueue({"type": "tool_result", "call_id": "child-B", "name": "x", "output": "y"})
|
||||
assert lq.get_nowait()["parent_call_id"] == "task-B"
|
||||
|
||||
|
||||
class TestAgentScopeInfoSuppression:
|
||||
"""While a task agent runs, its ``on_info`` progress chatter ("[task done] N
|
||||
chars", a tool's "fetched N chars") carries no call_id, so it can't nest
|
||||
under the task card. The web pane drops it for the duration rather than let
|
||||
it escape to the top level; the per-thread contextvar keeps it correct under
|
||||
the parent's parallel task pool (siblings in other threads aren't suppressed)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_scope(self):
|
||||
# The scope depth is a module-level contextvar that persists across tests
|
||||
# in the same thread; reset it around each so an unbalanced test (or a
|
||||
# leak from elsewhere) can't bleed suppression into another test.
|
||||
from turnstone.core.session_ui_base import _agent_scope_var
|
||||
|
||||
token = _agent_scope_var.set(0)
|
||||
yield
|
||||
_agent_scope_var.reset(token)
|
||||
|
||||
def test_on_info_suppressed_within_scope(self) -> None:
|
||||
ui = _make_ui()
|
||||
lq = ui._register_listener()
|
||||
ui.begin_agent_scope()
|
||||
ui.on_info("fetched 5663 chars, extracting...")
|
||||
ui.end_agent_scope()
|
||||
assert lq.empty()
|
||||
|
||||
def test_on_info_passes_through_outside_scope(self) -> None:
|
||||
ui = _make_ui()
|
||||
lq = ui._register_listener()
|
||||
ui.on_info("top-level status")
|
||||
assert lq.get_nowait() == {
|
||||
"type": "info",
|
||||
"message": "top-level status",
|
||||
"ws_id": "ws-1",
|
||||
"_event_id": 1,
|
||||
}
|
||||
|
||||
def test_nested_scopes_need_matching_exits(self) -> None:
|
||||
"""Parallel task agents: info stays suppressed until the LAST one
|
||||
leaves (the depth returns to zero)."""
|
||||
ui = _make_ui()
|
||||
lq = ui._register_listener()
|
||||
ui.begin_agent_scope()
|
||||
ui.begin_agent_scope()
|
||||
ui.end_agent_scope()
|
||||
ui.on_info("still inside a sibling task agent")
|
||||
assert lq.empty()
|
||||
ui.end_agent_scope()
|
||||
ui.on_info("now top-level again")
|
||||
assert lq.get_nowait()["message"] == "now top-level again"
|
||||
|
||||
def test_end_scope_floored_at_zero(self) -> None:
|
||||
"""An unmatched ``end_agent_scope`` can't drive the depth negative and
|
||||
wedge suppression off."""
|
||||
ui = _make_ui()
|
||||
lq = ui._register_listener()
|
||||
ui.end_agent_scope()
|
||||
ui.begin_agent_scope()
|
||||
ui.on_info("suppressed")
|
||||
assert lq.empty()
|
||||
|
||||
|
||||
class TestAgentTrajectoryStash:
|
||||
"""The recall store retains a finished task agent's projected sub-trajectory
|
||||
keyed by call_id, LRU-bounded. A miss is the honest "not retained" signal —
|
||||
/history then renders the flat parent record, never a fabricated 0-step card."""
|
||||
|
||||
def test_stash_and_get_roundtrip(self) -> None:
|
||||
ui = _make_ui()
|
||||
steps = [
|
||||
{"id": "t1::c1", "name": "search", "arguments": "{}", "output": "ok", "is_error": False}
|
||||
]
|
||||
ui.stash_agent_trajectory("t1", steps)
|
||||
assert ui.get_agent_trajectory("t1") == steps
|
||||
|
||||
def test_missing_returns_none(self) -> None:
|
||||
assert _make_ui().get_agent_trajectory("nope") is None
|
||||
|
||||
def test_empty_call_id_ignored(self) -> None:
|
||||
ui = _make_ui()
|
||||
ui.stash_agent_trajectory("", [{"id": "x"}])
|
||||
assert ui.get_agent_trajectory("") is None
|
||||
|
||||
def test_restash_updates_value(self) -> None:
|
||||
ui = _make_ui()
|
||||
ui.stash_agent_trajectory("k", [{"id": "v1"}])
|
||||
ui.stash_agent_trajectory("k", [{"id": "v2"}])
|
||||
assert ui.get_agent_trajectory("k") == [{"id": "v2"}]
|
||||
|
||||
def test_lru_evicts_oldest(self) -> None:
|
||||
from turnstone.core.session_ui_base import _AGENT_TRAJECTORY_CAP
|
||||
|
||||
ui = _make_ui()
|
||||
for i in range(_AGENT_TRAJECTORY_CAP + 3):
|
||||
ui.stash_agent_trajectory(f"t{i}", [{"id": f"t{i}"}])
|
||||
# The three oldest fell out → honest None; the newest is retained.
|
||||
assert ui.get_agent_trajectory("t0") is None
|
||||
assert ui.get_agent_trajectory("t2") is None
|
||||
assert ui.get_agent_trajectory(f"t{_AGENT_TRAJECTORY_CAP + 2}") is not None
|
||||
|
||||
@@ -1191,3 +1191,147 @@ class TestMCPToolGating:
|
||||
# session's ``user_id`` (sanity-check on the wiring).
|
||||
mcp_client.resource_count_for_user.assert_any_call("pool-only-user")
|
||||
mcp_client.prompt_count_for_user.assert_any_call("pool-only-user")
|
||||
|
||||
|
||||
class TestMCPActingUserBinding:
|
||||
"""Per-user MCP credentials follow the acting user on shared workstreams.
|
||||
|
||||
The workstream owner is the fallback identity; an authenticated send
|
||||
rebinds credential resolution (dispatch + catalogs + listeners) to the
|
||||
sender. Prepared tool items pin the identity at prepare time so a
|
||||
pending approval can't execute under a later sender's credentials.
|
||||
"""
|
||||
|
||||
def _make(self, mock_openai_client, owner="alice"):
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.call_tool_sync.return_value = "ok"
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
user_id=owner,
|
||||
)
|
||||
# Capture instead of persisting — same stub idiom as
|
||||
# test_session_mcp_dispatch_error.
|
||||
session._report_tool_result = MagicMock() # type: ignore[method-assign]
|
||||
return session, mcp_client
|
||||
|
||||
def test_effective_identity_defaults_to_owner(self, tmp_db, mock_openai_client):
|
||||
session, mcp_client = self._make(mock_openai_client)
|
||||
assert session._mcp_effective_user_id == "alice"
|
||||
item = session._prepare_mcp_tool("c1", "mcp__srv__tool", {})
|
||||
session._exec_mcp_tool(item)
|
||||
assert mcp_client.call_tool_sync.call_args.kwargs["user_id"] == "alice"
|
||||
|
||||
def test_bind_rebinds_dispatch_catalog_listeners_and_prime(self, tmp_db, mock_openai_client):
|
||||
session, mcp_client = self._make(mock_openai_client)
|
||||
mcp_client.reset_mock()
|
||||
|
||||
session.bind_acting_user("bob")
|
||||
|
||||
# Dispatch identity follows the acting user.
|
||||
item = session._prepare_mcp_tool("c1", "mcp__srv__tool", {})
|
||||
session._exec_mcp_tool(item)
|
||||
assert mcp_client.call_tool_sync.call_args.kwargs["user_id"] == "bob"
|
||||
# Listener registrations swapped from owner to acting user for
|
||||
# all three catalog kinds — identity is the (user_id, callback)
|
||||
# pair, so the remove must name the OLD uid and the add the new.
|
||||
mcp_client.remove_listener.assert_called_once_with(session._mcp_refresh_cb, user_id="alice")
|
||||
mcp_client.add_listener.assert_called_once_with(session._mcp_refresh_cb, user_id="bob")
|
||||
mcp_client.remove_resource_listener.assert_called_once_with(
|
||||
session._mcp_resource_cb, user_id="alice"
|
||||
)
|
||||
mcp_client.add_resource_listener.assert_called_once_with(
|
||||
session._mcp_resource_cb, user_id="bob"
|
||||
)
|
||||
mcp_client.remove_prompt_listener.assert_called_once_with(
|
||||
session._mcp_prompt_cb, user_id="alice"
|
||||
)
|
||||
mcp_client.add_prompt_listener.assert_called_once_with(
|
||||
session._mcp_prompt_cb, user_id="bob"
|
||||
)
|
||||
# The acting user's oauth_user pools are warmed so their tools
|
||||
# surface without a manual reconnect.
|
||||
mcp_client.prime_user_pools.assert_called_once_with("bob")
|
||||
# Merged tool list rebuilt under the new identity.
|
||||
mcp_client.get_tools.assert_any_call(user_id="bob")
|
||||
|
||||
def test_prepared_item_pins_identity_across_rebind(self, tmp_db, mock_openai_client):
|
||||
session, mcp_client = self._make(mock_openai_client)
|
||||
session.bind_acting_user("bob")
|
||||
item = session._prepare_mcp_tool("c1", "mcp__srv__tool", {})
|
||||
# A different user takes over the session while the item is
|
||||
# pending approval — execution must stay under the requester.
|
||||
session.bind_acting_user("carol")
|
||||
session._exec_mcp_tool(item)
|
||||
assert mcp_client.call_tool_sync.call_args.kwargs["user_id"] == "bob"
|
||||
|
||||
def test_resource_and_prompt_items_pin_identity(self, tmp_db, mock_openai_client):
|
||||
session, mcp_client = self._make(mock_openai_client)
|
||||
session.bind_acting_user("bob")
|
||||
res_item = session._prepare_read_resource("c1", {"uri": "res://x"})
|
||||
mcp_client.is_mcp_prompt.return_value = True
|
||||
prompt_item = session._prepare_use_prompt("c2", {"name": "p"})
|
||||
session.bind_acting_user("carol")
|
||||
assert res_item["mcp_user_id"] == "bob"
|
||||
assert prompt_item["mcp_user_id"] == "bob"
|
||||
# And the prompt-existence gate consults the CURRENT effective
|
||||
# identity (carol) for new preparations.
|
||||
session._prepare_use_prompt("c3", {"name": "p"})
|
||||
assert mcp_client.is_mcp_prompt.call_args.kwargs["user_id"] == "carol"
|
||||
|
||||
def test_bind_noops_on_empty_and_same_user(self, tmp_db, mock_openai_client):
|
||||
session, mcp_client = self._make(mock_openai_client)
|
||||
mcp_client.reset_mock()
|
||||
session.bind_acting_user("")
|
||||
session.bind_acting_user("alice") # same as owner
|
||||
mcp_client.remove_listener.assert_not_called()
|
||||
mcp_client.add_listener.assert_not_called()
|
||||
mcp_client.prime_user_pools.assert_not_called()
|
||||
assert session._mcp_effective_user_id == "alice"
|
||||
|
||||
def test_send_kwarg_binds_before_turn_starts(self, tmp_db, mock_openai_client):
|
||||
import pytest
|
||||
|
||||
session, _mcp_client = self._make(mock_openai_client)
|
||||
|
||||
class _SentinelError(Exception):
|
||||
pass
|
||||
|
||||
# ``bind_acting_user`` runs before ``_refresh_model_from_registry``
|
||||
# at the top of send() — abort there to prove the ordering without
|
||||
# driving the full agent loop.
|
||||
session._refresh_model_from_registry = MagicMock( # type: ignore[method-assign]
|
||||
side_effect=_SentinelError
|
||||
)
|
||||
with pytest.raises(_SentinelError):
|
||||
session.send("hi", acting_user_id="bob")
|
||||
assert session._acting_user_id == "bob"
|
||||
|
||||
def test_close_removes_listeners_under_rebound_identity(self, tmp_db, mock_openai_client):
|
||||
session, mcp_client = self._make(mock_openai_client)
|
||||
session.bind_acting_user("bob")
|
||||
refresh_cb = session._mcp_refresh_cb
|
||||
mcp_client.reset_mock()
|
||||
session.close()
|
||||
mcp_client.remove_listener.assert_called_once_with(refresh_cb, user_id="bob")
|
||||
|
||||
def test_bind_without_mcp_client_only_records(self, tmp_db, mock_openai_client):
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
user_id="alice",
|
||||
)
|
||||
session.bind_acting_user("bob")
|
||||
assert session._mcp_effective_user_id == "bob"
|
||||
|
||||
@@ -13,15 +13,17 @@ from turnstone.core.memory import (
|
||||
|
||||
class TestSaveStructuredMemory:
|
||||
def test_save_new(self, tmp_db):
|
||||
mid, old = save_structured_memory("test_key", "hello world")
|
||||
assert mid != ""
|
||||
assert old is None
|
||||
row, was_update = save_structured_memory("test_key", "hello world")
|
||||
assert row and row["memory_id"]
|
||||
assert was_update is False
|
||||
|
||||
def test_save_upsert(self, tmp_db):
|
||||
save_structured_memory("test_key", "first")
|
||||
mid, old = save_structured_memory("test_key", "second")
|
||||
assert old == "first"
|
||||
assert mid != ""
|
||||
row1, was_update1 = save_structured_memory("test_key", "first")
|
||||
row2, was_update2 = save_structured_memory("test_key", "second")
|
||||
assert was_update1 is False
|
||||
assert was_update2 is True
|
||||
assert row2 and row1 and row2["memory_id"] == row1["memory_id"] # same row
|
||||
assert row2["content"] == "second"
|
||||
|
||||
def test_save_normalizes_key(self, tmp_db):
|
||||
save_structured_memory("My-Key", "value")
|
||||
|
||||
@@ -28,29 +28,80 @@ class TestCreateAndGet:
|
||||
assert w["content"] == "w"
|
||||
|
||||
|
||||
class TestUpdate:
|
||||
def test_update_content(self, backend):
|
||||
backend.create_structured_memory("m1", "k", "d", "general", "global", "", "old")
|
||||
assert backend.update_structured_memory("m1", content="new")
|
||||
mem = backend.get_structured_memory("m1")
|
||||
assert mem["content"] == "new"
|
||||
class TestSaveUpsert:
|
||||
"""``save_structured_memory`` upserts by (name, scope, scope_id).
|
||||
|
||||
def test_update_nonexistent(self, backend):
|
||||
assert not backend.update_structured_memory("nope", content="x")
|
||||
A second save of the same key must UPDATE in place, not surface the
|
||||
``uq_smem_name_scope`` unique-constraint violation. These run on whichever
|
||||
backend ``--storage-backend`` selects, so the PostgreSQL path is covered in
|
||||
CI -- the session-level memory tests only exercise SQLite (via ``tmp_db``),
|
||||
which is where this path previously had no cross-backend coverage.
|
||||
"""
|
||||
|
||||
def test_update_no_fields(self, backend):
|
||||
backend.create_structured_memory("m1", "k", "d", "general", "global", "", "data")
|
||||
assert not backend.update_structured_memory("m1", bogus="val")
|
||||
def test_duplicate_create_raises_integrity_error(self, backend):
|
||||
"""The unique constraint the upsert's ON CONFLICT targets actually fires."""
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
def test_update_bumps_timestamp(self, backend):
|
||||
backend.create_structured_memory("m1", "k", "d", "general", "global", "", "data")
|
||||
old = backend.get_structured_memory("m1")["updated"]
|
||||
import time
|
||||
backend.create_structured_memory("m1", "dup", "", "general", "global", "", "a")
|
||||
with pytest.raises(sa.exc.IntegrityError):
|
||||
backend.create_structured_memory("m2", "dup", "", "general", "global", "", "b")
|
||||
|
||||
time.sleep(0.01)
|
||||
backend.update_structured_memory("m1", content="new")
|
||||
new = backend.get_structured_memory("m1")["updated"]
|
||||
assert new >= old
|
||||
def test_save_same_key_updates_in_place(self, backend):
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
row1, was_update1 = save_structured_memory("upsert_key", "v1", scope="global")
|
||||
assert row1 and was_update1 is False # inserted
|
||||
|
||||
row2, was_update2 = save_structured_memory("upsert_key", "v2", scope="global")
|
||||
assert row2 and was_update2 is True # updated in place
|
||||
assert row2["memory_id"] == row1["memory_id"] # same row, not a duplicate
|
||||
assert row2["content"] == "v2"
|
||||
names = [r["name"] for r in backend.list_structured_memories(scope="global")]
|
||||
assert names.count("upsert_key") == 1
|
||||
|
||||
def test_save_same_key_preserves_description_and_type_on_default_resave(self, backend):
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory(
|
||||
"meta_key", "c1", description="orig desc", mem_type="fact", scope="global"
|
||||
)
|
||||
# A re-save that omits description/type (defaults) must not clobber them.
|
||||
save_structured_memory("meta_key", "c2", scope="global")
|
||||
row = backend.get_structured_memory_by_name("meta_key", "global", "")
|
||||
assert row["content"] == "c2"
|
||||
assert row["description"] == "orig desc"
|
||||
assert row["type"] == "fact"
|
||||
|
||||
def test_upsert_method_updates_in_place_no_raise(self, backend):
|
||||
"""The atomic storage primitive updates in place on a key conflict and
|
||||
returns (row, was_update) carrying the existing row's id -- no
|
||||
IntegrityError (which a second create_structured_memory would raise)."""
|
||||
backend.create_structured_memory("m1", "k", "desc", "fact", "global", "", "v1")
|
||||
row, was_update = backend.upsert_structured_memory(
|
||||
"m2", "k", "newdesc", "note", "global", "", "v2"
|
||||
)
|
||||
assert was_update is True
|
||||
assert row["memory_id"] == "m1" # existing row id, not the supplied "m2"
|
||||
assert row["content"] == "v2"
|
||||
assert row["description"] == "newdesc"
|
||||
assert row["type"] == "note"
|
||||
names = [r["name"] for r in backend.list_structured_memories(scope="global")]
|
||||
assert names.count("k") == 1
|
||||
|
||||
def test_upsert_none_preserves_explicit_overwrites(self, backend):
|
||||
"""None description/type keep the stored value on conflict; an explicit
|
||||
value (including "" / "general") overwrites it."""
|
||||
backend.create_structured_memory("m1", "k", "keepdesc", "fact", "global", "", "v1")
|
||||
# None -> preserve stored description/type (a content-only save).
|
||||
row, _ = backend.upsert_structured_memory("m2", "k", None, None, "global", "", "v2")
|
||||
assert row["content"] == "v2"
|
||||
assert row["description"] == "keepdesc"
|
||||
assert row["type"] == "fact"
|
||||
# Explicit "" / "general" -> overwrite.
|
||||
row2, _ = backend.upsert_structured_memory("m3", "k", "", "general", "global", "", "v3")
|
||||
assert row2["description"] == ""
|
||||
assert row2["type"] == "general"
|
||||
|
||||
|
||||
class TestDelete:
|
||||
|
||||
@@ -88,6 +88,7 @@ class TestTruncateOutput:
|
||||
|
||||
class TestRemainingTokenBudget:
|
||||
def test_empty_session(self, session):
|
||||
session._tools = [] # isolate the budget formula from the tool-def estimate
|
||||
session._system_tokens = 500
|
||||
session._msg_tokens = []
|
||||
budget = session._remaining_token_budget()
|
||||
@@ -95,6 +96,7 @@ class TestRemainingTokenBudget:
|
||||
assert budget == 8000
|
||||
|
||||
def test_partially_full(self, session):
|
||||
session._tools = [] # isolate the budget formula from the tool-def estimate
|
||||
session._system_tokens = 500
|
||||
session._msg_tokens = [2000, 3000]
|
||||
budget = session._remaining_token_budget()
|
||||
@@ -123,6 +125,7 @@ class TestRemainingTokenBudget:
|
||||
context_window=32_768,
|
||||
max_tokens=32_768,
|
||||
)
|
||||
s._tools = [] # isolate the budget formula from the tool-def estimate
|
||||
s._system_tokens = 500
|
||||
s._msg_tokens = [1000]
|
||||
budget = s._remaining_token_budget()
|
||||
@@ -172,7 +175,9 @@ class TestContextOverflowRecovery:
|
||||
):
|
||||
session.send("hello")
|
||||
|
||||
compact_mock.assert_called_once_with(auto=True)
|
||||
# my_generation must be the send's own generation — a stale send that
|
||||
# hits overflow must not compact-and-swap a newer generation's history.
|
||||
compact_mock.assert_called_once_with(auto=True, my_generation=session._generation)
|
||||
assert call_count == 2
|
||||
|
||||
def test_anthropic_prompt_too_long_triggers_compact(self, session):
|
||||
@@ -203,7 +208,7 @@ class TestContextOverflowRecovery:
|
||||
):
|
||||
session.send("hello")
|
||||
|
||||
compact_mock.assert_called_once_with(auto=True)
|
||||
compact_mock.assert_called_once_with(auto=True, my_generation=session._generation)
|
||||
|
||||
def test_non_context_error_propagates(self, session):
|
||||
session.messages = turns_from_dicts([{"role": "user", "content": "hi"}])
|
||||
|
||||
@@ -1117,6 +1117,77 @@ class TestExportInteractive:
|
||||
assert "should not leak" not in r.text
|
||||
|
||||
|
||||
class TestHistoryAgentStepsOverlay:
|
||||
"""The history handler attaches a live task agent's stashed sub-trajectory to
|
||||
its ``task_agent`` tool_call (``agent_steps``) so the client rebuilds the
|
||||
card. A cold ws / evicted entry has none → no overlay (honest flat row)."""
|
||||
|
||||
def _save_task_agent_turn(self, storage, ws_id):
|
||||
storage.register_workstream(ws_id, kind="interactive", user_id="test-user")
|
||||
storage.save_message(ws_id, "user", "kick off")
|
||||
tc_json = json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "task1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "task_agent",
|
||||
"arguments": '{"prompt":"find call sites"}',
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
storage.save_message(ws_id, "assistant", "Working", tool_calls=tc_json)
|
||||
storage.save_message(ws_id, "tool", "4 call sites found", tool_call_id="task1")
|
||||
|
||||
def test_attaches_agent_steps_from_live_stash(self, _inject_storage):
|
||||
ws_id = "ws-recall-warm"
|
||||
self._save_task_agent_turn(_inject_storage, ws_id)
|
||||
steps = [
|
||||
{
|
||||
"id": "task1::c1",
|
||||
"name": "search",
|
||||
"arguments": "{}",
|
||||
"output": "12 matches",
|
||||
"is_error": False,
|
||||
}
|
||||
]
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = ws_id
|
||||
mock_ws.ui._pending_approval = None
|
||||
mock_ws.ui.get_agent_trajectory = lambda cid: steps if cid == "task1" else None
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get.return_value = mock_ws
|
||||
|
||||
r = _build_history_app(mock_mgr, _inject_storage).get(
|
||||
f"/v1/api/workstreams/{ws_id}/history"
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assistant = next(m for m in r.json()["messages"] if m.get("role") == "assistant")
|
||||
tc = assistant["tool_calls"][0]
|
||||
assert tc["id"] == "task1"
|
||||
assert tc["agent_steps"] == steps
|
||||
|
||||
def test_no_overlay_when_not_retained(self, _inject_storage):
|
||||
# Cold / evicted: get_agent_trajectory returns None → no agent_steps key,
|
||||
# so the client renders the flat parent record (never a 0-step card).
|
||||
ws_id = "ws-recall-cold"
|
||||
self._save_task_agent_turn(_inject_storage, ws_id)
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = ws_id
|
||||
mock_ws.ui._pending_approval = None
|
||||
mock_ws.ui.get_agent_trajectory = lambda cid: None
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get.return_value = mock_ws
|
||||
|
||||
r = _build_history_app(mock_mgr, _inject_storage).get(
|
||||
f"/v1/api/workstreams/{ws_id}/history"
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assistant = next(m for m in r.json()["messages"] if m.get("role") == "assistant")
|
||||
assert "agent_steps" not in assistant["tool_calls"][0]
|
||||
|
||||
|
||||
class TestHistoryInteractive:
|
||||
"""Interactive parity for the lifted ``GET /v1/api/workstreams/{ws_id}/history``."""
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.7.0a3"
|
||||
__version__ = "1.7.0a6"
|
||||
|
||||
@@ -441,6 +441,7 @@ class SavedWorkstreamInfo(BaseModel):
|
||||
child_count: int = 0
|
||||
context_tokens: int = 0
|
||||
context_ratio: float = 0.0
|
||||
project_id: str | None = None
|
||||
|
||||
|
||||
class ListSavedWorkstreamsResponse(BaseModel):
|
||||
|
||||
@@ -285,6 +285,12 @@ class TerminalUI(SessionUI):
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
|
||||
pass # Terminal shows spinner during tool execution
|
||||
|
||||
def on_agent_step(self, parent_call_id: str, item: dict[str, Any]) -> None:
|
||||
# CLI keeps an inline "leg" per sub-agent step — the structured nesting
|
||||
# card is web-only.
|
||||
hdr = item.get("header") or item.get("func_name") or "tool"
|
||||
print(f"{DIM} - {hdr}{RESET}")
|
||||
|
||||
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None:
|
||||
total_tok = usage["prompt_tokens"] + usage["completion_tokens"]
|
||||
pct = total_tok / context_window * 100 if context_window > 0 else 0
|
||||
|
||||
@@ -540,6 +540,9 @@ class ClusterCollector:
|
||||
"kind": WorkstreamKind.from_raw(ws.get("kind")),
|
||||
"parent_ws_id": ws.get("parent_ws_id"),
|
||||
"project_id": ws.get("project_id", "") or "",
|
||||
# Mirror the SSE-relay path: the tenancy filter's
|
||||
# ws-creator shortcut reads this.
|
||||
"user_id": ws.get("user_id", "") or "",
|
||||
}
|
||||
)
|
||||
# Removals
|
||||
@@ -932,6 +935,7 @@ class ClusterCollector:
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
extra_rows: list[dict[str, Any]] | None = None,
|
||||
row_filter: Callable[[dict[str, Any]], bool] | None = None,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Return filtered, sorted, paginated workstreams + total count.
|
||||
|
||||
@@ -939,6 +943,10 @@ class ClusterCollector:
|
||||
filter / sort / paginate — used by callers that contribute
|
||||
console-local rows (e.g. coordinator workstreams) that aren't
|
||||
tracked on any node's SSE stream.
|
||||
|
||||
``row_filter`` (when provided) runs against the merged,
|
||||
UNPAGINATED pool so dropped rows never skew ``total`` or page
|
||||
boundaries — the private-project tenancy filter rides here.
|
||||
"""
|
||||
with self._lock:
|
||||
all_ws = []
|
||||
@@ -955,6 +963,10 @@ class ClusterCollector:
|
||||
if extra_rows:
|
||||
all_ws.extend(dict(r) for r in extra_rows)
|
||||
|
||||
# Row-level tenancy filter first — before pagination math.
|
||||
if row_filter is not None:
|
||||
all_ws = [ws for ws in all_ws if row_filter(ws)]
|
||||
|
||||
# Filter
|
||||
if state:
|
||||
all_ws = [ws for ws in all_ws if ws.get("state") == state]
|
||||
@@ -1161,6 +1173,7 @@ class ClusterCollector:
|
||||
kind: str,
|
||||
state: str = "idle",
|
||||
parent_ws_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record a new coordinator row on the console pseudo-node + fan out.
|
||||
|
||||
@@ -1192,6 +1205,9 @@ class ClusterCollector:
|
||||
"kind": kind,
|
||||
"parent_ws_id": parent_ws_id,
|
||||
"user_id": user_id or "",
|
||||
# Tenancy-load-bearing: the per-connection SSE filter
|
||||
# gates on this — a missing project_id fails open.
|
||||
"project_id": project_id or "",
|
||||
"updated": now,
|
||||
}
|
||||
pending.append(
|
||||
@@ -1204,6 +1220,7 @@ class ClusterCollector:
|
||||
"kind": kind,
|
||||
"parent_ws_id": parent_ws_id,
|
||||
"user_id": user_id or "",
|
||||
"project_id": project_id or "",
|
||||
}
|
||||
)
|
||||
for event in pending:
|
||||
|
||||
@@ -161,6 +161,7 @@ class CoordinatorAdapter:
|
||||
kind=ws.kind.value,
|
||||
state=ws.state.value,
|
||||
parent_ws_id=None,
|
||||
project_id=ws.project_id,
|
||||
)
|
||||
except Exception:
|
||||
log.debug("coord_adapter.created_fanout_failed ws=%s", ws.id[:8], exc_info=True)
|
||||
@@ -505,6 +506,7 @@ class CoordinatorAdapter:
|
||||
kind=WorkstreamKind.COORDINATOR.value,
|
||||
state=ws.state.value,
|
||||
parent_ws_id=None,
|
||||
project_id=ws.project_id,
|
||||
)
|
||||
except Exception:
|
||||
log.debug(
|
||||
|
||||
+186
-3
@@ -995,6 +995,8 @@ def _coordinator_rows(request: Request) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
async def cluster_workstreams(request: Request) -> JSONResponse:
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
params = dict(request.query_params)
|
||||
state = params.get("state")
|
||||
@@ -1004,7 +1006,11 @@ async def cluster_workstreams(request: Request) -> JSONResponse:
|
||||
page = _parse_int(params, "page", 1, minimum=1)
|
||||
per_page = _parse_int(params, "per_page", 50, minimum=1, maximum=200)
|
||||
extra_rows = _coordinator_rows(request)
|
||||
ws_list, total = collector.get_workstreams(
|
||||
visibility = WorkstreamProjectVisibility.for_request(request)
|
||||
# Executor: the tenancy row_filter resolves project rows from storage,
|
||||
# so the whole collect+filter+paginate runs off the event loop.
|
||||
ws_list, total = await asyncio.to_thread(
|
||||
collector.get_workstreams,
|
||||
state=state,
|
||||
node=node,
|
||||
search=search,
|
||||
@@ -1012,6 +1018,9 @@ async def cluster_workstreams(request: Request) -> JSONResponse:
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
extra_rows=extra_rows,
|
||||
row_filter=lambda ws: visibility.ws_visible(
|
||||
ws.get("project_id") or "", ws_owner=ws.get("user_id") or ""
|
||||
),
|
||||
)
|
||||
pages = math.ceil(total / per_page) if per_page > 0 else 0
|
||||
return JSONResponse(
|
||||
@@ -1506,6 +1515,8 @@ async def cluster_ws_live_bulk(request: Request) -> JSONResponse:
|
||||
|
||||
|
||||
async def cluster_node_detail(request: Request) -> JSONResponse:
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
node_id = request.path_params["node_id"]
|
||||
nv = _validate_node_id(node_id)
|
||||
@@ -1514,6 +1525,18 @@ async def cluster_node_detail(request: Request) -> JSONResponse:
|
||||
detail = collector.get_node_detail(node_id)
|
||||
if not detail:
|
||||
return JSONResponse({"error": "Node not found"}, status_code=404)
|
||||
# Private-project tenancy — same predicate as the cluster list.
|
||||
# Executor: the predicate resolves project rows from storage.
|
||||
visibility = WorkstreamProjectVisibility.for_request(request)
|
||||
|
||||
def _filter_ws_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
ws
|
||||
for ws in rows
|
||||
if visibility.ws_visible(ws.get("project_id") or "", ws_owner=ws.get("user_id") or "")
|
||||
]
|
||||
|
||||
detail["workstreams"] = await asyncio.to_thread(_filter_ws_rows, detail.get("workstreams", []))
|
||||
|
||||
# Attach metadata if available
|
||||
import json as _nd_json
|
||||
@@ -1555,21 +1578,151 @@ def _collector_scope_error(request: Request) -> JSONResponse | None:
|
||||
return None
|
||||
|
||||
|
||||
class _ClusterTenancyFilter:
|
||||
"""Per-connection/request private-project tenancy for cluster payloads.
|
||||
|
||||
Wraps a :class:`WorkstreamProjectVisibility` with the state the
|
||||
cluster surfaces need: the snapshot carries full rows (project_id +
|
||||
user_id) but follow-up SSE events are sparse (usually just ws_id),
|
||||
so invisible workstreams are recorded in ``_hidden`` at
|
||||
snapshot/ws_created time and every later event naming them is
|
||||
swallowed. A row whose project lookup fails transiently lands in
|
||||
``_unresolved`` instead — suppressed but re-judged (rate-limited) on
|
||||
its later events, so a DB blip neither leaks a private workstream
|
||||
nor pins a public one invisible until reconnect. Access changes
|
||||
(membership grant/revoke) still take effect on reconnect — same
|
||||
resolve-once precedent as session construction. The visibility
|
||||
instance memoizes project rows, so the steady-state per-event cost
|
||||
is a set lookup.
|
||||
"""
|
||||
|
||||
_RETRY_INTERVAL_S = 5.0
|
||||
|
||||
def __init__(self, visibility: Any) -> None:
|
||||
self._vis = visibility
|
||||
# Bypass principals (service scope / admin.cluster.inspect) get
|
||||
# the payload UNTOUCHED — no row drops, and crucially no
|
||||
# overview recompute (their header should reflect the
|
||||
# collector's own aggregates).
|
||||
self._bypass = bool(getattr(visibility, "bypass", False))
|
||||
self._hidden: set[str] = set()
|
||||
# wid -> (project_id, ws_owner) awaiting a definitive verdict.
|
||||
self._unresolved: dict[str, tuple[str, str]] = {}
|
||||
self._retry_after: dict[str, float] = {}
|
||||
|
||||
@staticmethod
|
||||
def _row_ws_id(ws: dict[str, Any]) -> str:
|
||||
return str(ws.get("ws_id") or ws.get("id") or "")
|
||||
|
||||
def _judge(self, wid: str, project_id: str, ws_owner: str) -> bool:
|
||||
"""Tri-state check folded into the connection state.
|
||||
|
||||
Undetermined (storage blip) suppresses the row/event but leaves
|
||||
it re-judgeable; definitive verdicts settle into shown/hidden.
|
||||
"""
|
||||
verdict = self._vis.ws_visibility(project_id, ws_owner=ws_owner)
|
||||
if verdict is None:
|
||||
if wid:
|
||||
self._unresolved[wid] = (project_id, ws_owner)
|
||||
self._retry_after[wid] = time.monotonic() + self._RETRY_INTERVAL_S
|
||||
return False
|
||||
if wid:
|
||||
self._unresolved.pop(wid, None)
|
||||
self._retry_after.pop(wid, None)
|
||||
if verdict:
|
||||
self._hidden.discard(wid)
|
||||
else:
|
||||
self._hidden.add(wid)
|
||||
return bool(verdict)
|
||||
|
||||
def filter_snapshot(self, snap: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Drop invisible rows from every node list and re-derive the
|
||||
overview aggregates the collector computed over the UNFILTERED
|
||||
rows — otherwise the header count/state histogram leaks the
|
||||
existence and lifecycle of hidden workstreams."""
|
||||
if self._bypass:
|
||||
return snap
|
||||
total = 0
|
||||
states: dict[str, int] = {}
|
||||
for node in snap.get("nodes", []):
|
||||
kept: list[dict[str, Any]] = []
|
||||
for ws in node.get("workstreams", []):
|
||||
wid = self._row_ws_id(ws)
|
||||
if self._judge(wid, ws.get("project_id") or "", ws.get("user_id") or ""):
|
||||
kept.append(ws)
|
||||
state = str(ws.get("state") or "idle")
|
||||
states[state] = states.get(state, 0) + 1
|
||||
total += 1
|
||||
node["workstreams"] = kept
|
||||
overview = snap.get("overview")
|
||||
if isinstance(overview, dict):
|
||||
overview["workstreams"] = total
|
||||
prior_states = overview.get("states")
|
||||
if isinstance(prior_states, dict):
|
||||
rebuilt = dict.fromkeys(prior_states, 0)
|
||||
rebuilt.update(states)
|
||||
overview["states"] = rebuilt
|
||||
return snap
|
||||
|
||||
def event_visible(self, event: dict[str, Any]) -> bool:
|
||||
if self._bypass:
|
||||
return True
|
||||
etype = event.get("type")
|
||||
wid = str(event.get("ws_id") or "")
|
||||
if etype == "ws_created":
|
||||
return self._judge(wid, event.get("project_id") or "", event.get("user_id") or "")
|
||||
if etype == "ws_closed" and wid:
|
||||
was_suppressed = wid in self._hidden or wid in self._unresolved
|
||||
self._hidden.discard(wid)
|
||||
self._unresolved.pop(wid, None)
|
||||
self._retry_after.pop(wid, None)
|
||||
return not was_suppressed
|
||||
if wid and wid in self._unresolved:
|
||||
if time.monotonic() >= self._retry_after.get(wid, 0.0):
|
||||
pid, owner = self._unresolved[wid]
|
||||
return self._judge(wid, pid, owner)
|
||||
return False
|
||||
return not (wid and wid in self._hidden)
|
||||
|
||||
def event_touches_storage(self, event: dict[str, Any]) -> bool:
|
||||
"""True when judging this event may perform a project lookup —
|
||||
the caller offloads those to the executor."""
|
||||
if self._bypass:
|
||||
return False
|
||||
wid = str(event.get("ws_id") or "")
|
||||
return event.get("type") == "ws_created" or wid in self._unresolved
|
||||
|
||||
|
||||
async def cluster_snapshot(request: Request) -> JSONResponse:
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
|
||||
err = _collector_scope_error(request)
|
||||
if err is not None:
|
||||
return err
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
return JSONResponse(collector.get_snapshot())
|
||||
# Same tenancy treatment as the SSE snapshot and the cluster list —
|
||||
# this endpoint served the raw collector state and was the one
|
||||
# remaining unfiltered window into private-project workstreams.
|
||||
tenancy = _ClusterTenancyFilter(WorkstreamProjectVisibility.for_request(request))
|
||||
|
||||
def _collect() -> dict[str, Any]:
|
||||
return tenancy.filter_snapshot(collector.get_snapshot())
|
||||
|
||||
return JSONResponse(await asyncio.to_thread(_collect))
|
||||
|
||||
|
||||
async def cluster_events_sse(request: Request) -> Response:
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
|
||||
err = _collector_scope_error(request)
|
||||
if err is not None:
|
||||
return err
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
client_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=2000)
|
||||
|
||||
# Per-connection private-project tenancy — see _ClusterTenancyFilter.
|
||||
tenancy = _ClusterTenancyFilter(WorkstreamProjectVisibility.for_request(request))
|
||||
|
||||
async def event_generator() -> AsyncGenerator[dict[str, str], None]:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
@@ -1578,6 +1731,9 @@ async def cluster_events_sse(request: Request) -> Response:
|
||||
None, collector.get_snapshot_and_register, client_queue
|
||||
)
|
||||
snap["type"] = "snapshot"
|
||||
# Executor: the snapshot filter resolves project rows from
|
||||
# storage — never block the event loop on DB I/O.
|
||||
snap = await loop.run_in_executor(None, tenancy.filter_snapshot, snap)
|
||||
yield {"data": json.dumps(snap)}
|
||||
|
||||
while True:
|
||||
@@ -1585,7 +1741,16 @@ async def cluster_events_sse(request: Request) -> Response:
|
||||
event = await loop.run_in_executor(
|
||||
None, functools.partial(client_queue.get, timeout=5)
|
||||
)
|
||||
yield {"data": json.dumps(event)}
|
||||
# Judgments that may hit storage (ws_created, or a
|
||||
# retry of an unresolved row) run on the executor;
|
||||
# everything else is a pure set-membership check and
|
||||
# stays on the loop.
|
||||
if tenancy.event_touches_storage(event):
|
||||
visible = await loop.run_in_executor(None, tenancy.event_visible, event)
|
||||
else:
|
||||
visible = tenancy.event_visible(event)
|
||||
if visible:
|
||||
yield {"data": json.dumps(event)}
|
||||
except queue.Empty:
|
||||
pass # poll timeout, retry
|
||||
if await request.is_disconnected():
|
||||
@@ -3393,6 +3558,19 @@ async def _coord_create_validate_request(
|
||||
"""
|
||||
if not uid:
|
||||
return JSONResponse({"error": "authentication required"}, status_code=401)
|
||||
# Project attach gate — same rule as the interactive validator: a
|
||||
# private project accepts new workstreams only from its owner or
|
||||
# members, and a nonexistent project_id 400s rather than minting a
|
||||
# dangling link.
|
||||
project_raw = body.get("project_id")
|
||||
attach_pid = (project_raw.strip() if isinstance(project_raw, str) else "") or ""
|
||||
if attach_pid:
|
||||
from turnstone.core.auth import ensure_project_attachable
|
||||
|
||||
denied = ensure_project_attachable(uid, attach_pid)
|
||||
if denied is not None:
|
||||
status, message = denied
|
||||
return JSONResponse({"error": message}, status_code=status)
|
||||
return None
|
||||
|
||||
|
||||
@@ -13022,6 +13200,7 @@ def create_app(
|
||||
get_project_endpoint,
|
||||
list_project_members_endpoint,
|
||||
list_projects,
|
||||
project_resources_endpoint,
|
||||
remove_project_member_endpoint,
|
||||
update_project_endpoint,
|
||||
)
|
||||
@@ -13438,6 +13617,10 @@ def create_app(
|
||||
delete_project_endpoint,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
Route(
|
||||
"/api/projects/{project_id}/resources",
|
||||
project_resources_endpoint,
|
||||
),
|
||||
Route(
|
||||
"/api/projects/{project_id}/members",
|
||||
list_project_members_endpoint,
|
||||
|
||||
@@ -2522,10 +2522,11 @@ function _renderProjects(projects) {
|
||||
const p = projects[i];
|
||||
const archived = p.state === "archived";
|
||||
html +=
|
||||
'<div class="admin-row" role="listitem" data-project-id="' +
|
||||
'<div class="admin-row" role="listitem" data-expandable data-project-id="' +
|
||||
escapeHtml(p.project_id) +
|
||||
'">' +
|
||||
'" tabindex="0" aria-expanded="false">' +
|
||||
'<span class="admin-col admin-col-username">' +
|
||||
'<span class="admin-expand-indicator" aria-hidden="true">▸</span>' +
|
||||
escapeHtml(p.name) +
|
||||
"</span>" +
|
||||
'<span class="admin-col admin-col-name">' +
|
||||
@@ -2598,6 +2599,197 @@ function _bindProjectRowActions(container) {
|
||||
);
|
||||
});
|
||||
});
|
||||
// Expandable per-project resources panel (workstreams / attachments /
|
||||
// memory) — same interaction contract as the Users tab's OIDC panel.
|
||||
container
|
||||
.querySelectorAll(".admin-row[data-expandable]")
|
||||
.forEach(function (row) {
|
||||
const _expand = function () {
|
||||
_toggleProjectPanel(row.getAttribute("data-project-id"), row);
|
||||
};
|
||||
row.addEventListener("click", function (e) {
|
||||
// Clicks on the row's kebab menu must not also toggle the panel.
|
||||
if (
|
||||
e.target.closest(".admin-kebab") ||
|
||||
e.target.closest(".admin-btn-danger") ||
|
||||
e.target.closest(".admin-btn-action")
|
||||
)
|
||||
return;
|
||||
_expand();
|
||||
});
|
||||
row.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
_expand();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _toggleProjectPanel(projectId, rowEl) {
|
||||
const existing = rowEl.nextElementSibling;
|
||||
if (existing && existing.classList.contains("proj-detail-panel")) {
|
||||
// Collapse
|
||||
existing.style.maxHeight = "0";
|
||||
const indicator = rowEl.querySelector(".admin-expand-indicator");
|
||||
if (indicator) indicator.classList.remove("expanded");
|
||||
rowEl.setAttribute("aria-expanded", "false");
|
||||
setTimeout(function () {
|
||||
if (existing.parentNode) existing.remove();
|
||||
}, 160);
|
||||
return;
|
||||
}
|
||||
// Collapse any other open panel first (single-open accordion).
|
||||
const openPanels = document.querySelectorAll(
|
||||
"#admin-projects-table .proj-detail-panel",
|
||||
);
|
||||
for (let i = 0; i < openPanels.length; i++) {
|
||||
openPanels[i].style.maxHeight = "0";
|
||||
const prevRow = openPanels[i].previousElementSibling;
|
||||
if (prevRow) {
|
||||
const ind = prevRow.querySelector(".admin-expand-indicator");
|
||||
if (ind) ind.classList.remove("expanded");
|
||||
prevRow.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
(function (panel) {
|
||||
setTimeout(function () {
|
||||
if (panel.parentNode) panel.remove();
|
||||
}, 160);
|
||||
})(openPanels[i]);
|
||||
}
|
||||
const indicator = rowEl.querySelector(".admin-expand-indicator");
|
||||
if (indicator) indicator.classList.add("expanded");
|
||||
rowEl.setAttribute("aria-expanded", "true");
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "proj-detail-panel";
|
||||
panel.setAttribute("role", "none");
|
||||
setSafeHtml(
|
||||
panel,
|
||||
'<div class="proj-detail-inner">' +
|
||||
'<div class="proj-detail-body"><span class="proj-detail-empty">Loading…</span></div>' +
|
||||
"</div>",
|
||||
);
|
||||
rowEl.after(panel);
|
||||
requestAnimationFrame(function () {
|
||||
panel.style.maxHeight = panel.scrollHeight + "px";
|
||||
});
|
||||
authFetch("/v1/api/projects/" + encodeURIComponent(projectId) + "/resources")
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
_renderProjectResources(panel, data);
|
||||
})
|
||||
.catch(function () {
|
||||
const body = panel.querySelector(".proj-detail-body");
|
||||
if (body)
|
||||
setSafeHtml(
|
||||
body,
|
||||
'<span class="proj-detail-empty">Failed to load</span>',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const _PROJ_ATT_ICONS = { image: "\u{1f5bc}", audio: "\u{1f3b5}" };
|
||||
|
||||
function _projAttachmentHref(att) {
|
||||
// Content serving is ws-scoped and node-local: interactive workstreams
|
||||
// route through the console's transparent node proxy; coordinator
|
||||
// workstreams (no node_id recorded on the attachment's ws row here)
|
||||
// serve from the console's own coord attachment routes.
|
||||
const tail =
|
||||
"v1/api/workstreams/" +
|
||||
encodeURIComponent(att.ws_id) +
|
||||
"/attachments/" +
|
||||
encodeURIComponent(att.attachment_id) +
|
||||
"/content";
|
||||
return att.node_id
|
||||
? "/node/" + encodeURIComponent(att.node_id) + "/" + tail
|
||||
: "/" + tail;
|
||||
}
|
||||
|
||||
function _projFmtSize(n) {
|
||||
if (typeof n !== "number" || n < 0) return "";
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / 1048576).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
function _renderProjectResources(panel, data) {
|
||||
const body = panel.querySelector(".proj-detail-body");
|
||||
if (!body) return;
|
||||
const wss = data.workstreams || [];
|
||||
// node_id lives on the workstream rows; the attachment rows carry only
|
||||
// their first-referencing ws_id — join here for download URLs.
|
||||
const nodeByWs = {};
|
||||
for (let i = 0; i < wss.length; i++) nodeByWs[wss[i].ws_id] = wss[i].node_id;
|
||||
let html =
|
||||
'<div class="proj-detail-header">Workstreams (' + wss.length + ")</div>";
|
||||
if (!wss.length) {
|
||||
html += '<span class="proj-detail-empty">No workstreams</span>';
|
||||
} else {
|
||||
for (let i = 0; i < wss.length; i++) {
|
||||
const w = wss[i];
|
||||
html +=
|
||||
'<div class="proj-detail-row">' +
|
||||
'<span class="proj-detail-main">' +
|
||||
escapeHtml(w.title || w.name || w.ws_id.substring(0, 12)) +
|
||||
"</span>" +
|
||||
'<span class="proj-detail-dim">' +
|
||||
escapeHtml(String(w.kind || "")) +
|
||||
" · " +
|
||||
escapeHtml(String(w.state || "")) +
|
||||
" · " +
|
||||
escapeHtml(String(w.updated || "").slice(0, 10)) +
|
||||
" · " +
|
||||
escapeHtml(w.ws_id.substring(0, 7)) +
|
||||
"</span>" +
|
||||
"</div>";
|
||||
}
|
||||
}
|
||||
const atts = data.attachments || [];
|
||||
html +=
|
||||
'<div class="proj-detail-header">Attachments (' + atts.length + ")</div>";
|
||||
if (!atts.length) {
|
||||
html += '<span class="proj-detail-empty">No attachments</span>';
|
||||
} else {
|
||||
for (let i = 0; i < atts.length; i++) {
|
||||
const a = atts[i];
|
||||
const icon = _PROJ_ATT_ICONS[a.kind] || "\u{1f4c4}";
|
||||
a.node_id = nodeByWs[a.ws_id] || "";
|
||||
html +=
|
||||
'<div class="proj-detail-row">' +
|
||||
'<span class="proj-detail-main">' +
|
||||
'<span aria-hidden="true">' +
|
||||
icon +
|
||||
"</span> " +
|
||||
'<a class="proj-detail-link" target="_blank" rel="noopener" href="' +
|
||||
escapeHtml(_projAttachmentHref(a)) +
|
||||
'">' +
|
||||
escapeHtml(a.filename || a.attachment_id.substring(0, 12)) +
|
||||
"</a>" +
|
||||
"</span>" +
|
||||
'<span class="proj-detail-dim">' +
|
||||
escapeHtml(_projFmtSize(a.size_bytes)) +
|
||||
" · " +
|
||||
escapeHtml(String(a.created || "").slice(0, 10)) +
|
||||
"</span>" +
|
||||
"</div>";
|
||||
}
|
||||
}
|
||||
html +=
|
||||
'<div class="proj-detail-header">Memory</div>' +
|
||||
'<div class="proj-detail-row"><span class="proj-detail-main">' +
|
||||
String(data.memory_count || 0) +
|
||||
" project-scoped memor" +
|
||||
(data.memory_count === 1 ? "y" : "ies") +
|
||||
"</span></div>";
|
||||
setSafeHtml(body, html);
|
||||
// Re-measure after content lands so the animated max-height fits.
|
||||
requestAnimationFrame(function () {
|
||||
panel.style.maxHeight = panel.scrollHeight + "px";
|
||||
});
|
||||
}
|
||||
|
||||
function _projectById(pid) {
|
||||
|
||||
@@ -21,6 +21,10 @@ window.onLoginSuccess = function () {
|
||||
}
|
||||
};
|
||||
window.onLogout = function () {
|
||||
if (sseReconnectTimer) {
|
||||
clearTimeout(sseReconnectTimer);
|
||||
sseReconnectTimer = null;
|
||||
}
|
||||
if (evtSource) {
|
||||
evtSource.close();
|
||||
evtSource = null;
|
||||
@@ -67,6 +71,10 @@ let currentView = "home"; // "home" | "overview" | "filtered" | "admin"
|
||||
let currentFilter = { state: null, node: null, page: 1, per_page: 50 };
|
||||
let evtSource = null;
|
||||
let retryDelay = 1000;
|
||||
// Pending reconnect handle — tracked so logout (and a fresh connectSSE) can
|
||||
// cancel it; an untracked timer fired post-logout and opened a new
|
||||
// EventSource that 401s and re-probes in a loop.
|
||||
let sseReconnectTimer = null;
|
||||
let clusterState = null;
|
||||
let _navigatingFromPopstate = false;
|
||||
|
||||
@@ -346,6 +354,10 @@ function _fireRenderSubs() {
|
||||
|
||||
// --- SSE Connection ---
|
||||
function connectSSE() {
|
||||
if (sseReconnectTimer) {
|
||||
clearTimeout(sseReconnectTimer);
|
||||
sseReconnectTimer = null;
|
||||
}
|
||||
if (evtSource) {
|
||||
evtSource.close();
|
||||
evtSource = null;
|
||||
@@ -380,11 +392,11 @@ function connectSSE() {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
setTimeout(connectSSE, retryDelay);
|
||||
sseReconnectTimer = setTimeout(connectSSE, retryDelay);
|
||||
retryDelay = Math.min(retryDelay * 2, 30000);
|
||||
})
|
||||
.catch(function () {
|
||||
setTimeout(connectSSE, retryDelay);
|
||||
sseReconnectTimer = setTimeout(connectSSE, retryDelay);
|
||||
retryDelay = Math.min(retryDelay * 2, 30000);
|
||||
});
|
||||
};
|
||||
@@ -1899,6 +1911,7 @@ function _initSavedCoordTable() {
|
||||
return s.kind || "";
|
||||
},
|
||||
},
|
||||
SavedColumns.project(),
|
||||
SavedColumns.model(),
|
||||
SavedColumns.count("child_count", "CHILDREN", "92px"),
|
||||
SavedColumns.ctx(),
|
||||
@@ -2010,6 +2023,13 @@ function _initSavedCoordTable() {
|
||||
},
|
||||
},
|
||||
});
|
||||
// The PROJECT column resolves names from the shared projects cache,
|
||||
// which fills asynchronously — re-render once names arrive.
|
||||
if (window.TurnstoneProjects) {
|
||||
window.TurnstoneProjects.onProjectsChange(function () {
|
||||
if (_coordTable) _coordTable.render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// HTML inline-onclick wrappers — keep the global names the markup binds
|
||||
|
||||
@@ -2207,31 +2207,64 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
// Transient errors (network blips, intermediary timeouts) just
|
||||
// let native reconnect run — no scheduleReconnect needed
|
||||
// because the source isn't dead.
|
||||
var probe = typeof authFetch === "function" ? authFetch : fetch;
|
||||
probe("/v1/api/workstreams/" + encodeURIComponent(wsId)).then(
|
||||
function (r) {
|
||||
if (r.status === 401 && typeof showLogin === "function") {
|
||||
try {
|
||||
if (evtSource) evtSource.close();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
evtSource = null;
|
||||
// Cancel the pending CLOSED-state recovery timer (set
|
||||
// below). Without this, 5 s later the timer would
|
||||
// observe ``!evtSource`` and call ``scheduleReconnect``,
|
||||
// which would open a new EventSource that gets 401 again
|
||||
// → infinite reconnect loop while the login overlay is
|
||||
// up. The login flow re-arms ``connectSSE`` after a
|
||||
// successful sign-in via its own callback path.
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
showLogin("Session expired. Please sign in to reconnect.");
|
||||
}
|
||||
},
|
||||
);
|
||||
// Raw fetch (not authFetch) — need to inspect status before throwing.
|
||||
// authFetch never RESOLVES with a 401 (it calls showLogin() itself and
|
||||
// throws Error("auth")), so probing through it made this branch dead
|
||||
// code: the close/cancel-timer handling below never ran and the
|
||||
// CLOSED-state recovery kept cycling scheduleReconnect behind the
|
||||
// login overlay — exactly the loop this branch exists to prevent.
|
||||
// Mirrors the app.js dashboard probe. ``.catch``: a network-dead
|
||||
// probe is the transient case; native/manual reconnect owns it.
|
||||
//
|
||||
// The 401 body is inspected BEFORE the generic-expiry handling: a
|
||||
// code=version_mismatch body must take auth.js's upgrade path
|
||||
// (reload-after-re-login flag + "upgrade" overlay). The old authFetch
|
||||
// probe did that as a side effect of authFetch's own 401 handling; a
|
||||
// raw fetch must do it explicitly or a server upgrade leaves stale
|
||||
// pre-upgrade JS running after sign-in. NOTE the positive-form guard
|
||||
// (r.status === 401) directly above the close(): the reconnect-
|
||||
// contract pin (test_app_js._onerror_preserves_native_reconnect) keys
|
||||
// on that marker within a short window to allow a terminal close.
|
||||
fetch("/v1/api/workstreams/" + encodeURIComponent(wsId))
|
||||
.then(function (r) {
|
||||
if (!(r.status === 401 && typeof showLogin === "function")) return;
|
||||
return r
|
||||
.json()
|
||||
.catch(function () {
|
||||
return null;
|
||||
})
|
||||
.then(function (body) {
|
||||
try {
|
||||
if (evtSource) evtSource.close();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
evtSource = null;
|
||||
// Cancel the pending CLOSED-state recovery timer (set
|
||||
// below). Without this, 5 s later the timer would
|
||||
// observe ``!evtSource`` and call ``scheduleReconnect``,
|
||||
// which would open a new EventSource that gets 401 again
|
||||
// → infinite reconnect loop while the login overlay is
|
||||
// up. The login flow re-arms ``connectSSE`` after a
|
||||
// successful sign-in via its own callback path.
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (
|
||||
body &&
|
||||
body.code === "version_mismatch" &&
|
||||
typeof noteVersionMismatch === "function"
|
||||
) {
|
||||
noteVersionMismatch();
|
||||
} else {
|
||||
showLogin("Session expired. Please sign in to reconnect.");
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
/* transient network failure — reconnect machinery handles it */
|
||||
});
|
||||
// CLOSED-state recovery: native auto-reconnect covers the
|
||||
// transient case (source stays in CONNECTING and eventually
|
||||
// re-opens). But if the browser gives up — hard 4xx after
|
||||
@@ -3535,13 +3568,7 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
// pending count is maintained incrementally on cache mutations
|
||||
// (see ``pendingApprovalIds`` near the cache definition) so this
|
||||
// is O(1) per render rather than an O(N) walk over the cache.
|
||||
const pending = pendingApprovalIds.size;
|
||||
childrenCountEl.textContent = rows.length
|
||||
? "(" +
|
||||
rows.length +
|
||||
(pending > 0 ? " · " + pending + " pending" : "") +
|
||||
")"
|
||||
: "";
|
||||
_refreshChildrenCount();
|
||||
_restoreRowFocus(childrenTreeEl, focusKey);
|
||||
}
|
||||
|
||||
@@ -3562,13 +3589,32 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
const replacement = renderChildRow(entry);
|
||||
row.replaceWith(replacement);
|
||||
const obs = _getChildObserver();
|
||||
if (obs) obs.observe(replacement);
|
||||
if (obs) {
|
||||
// Release the detached row from the persistent observer — this is
|
||||
// now the hot path (every child_ws_state tick), and observed-but-
|
||||
// detached rows are strong refs that would accumulate without bound
|
||||
// between full renders (which reset targets via disconnect()).
|
||||
obs.unobserve(row);
|
||||
obs.observe(replacement);
|
||||
}
|
||||
_restoreRowFocus(replacement, focusKey);
|
||||
// Keep the "(N · x pending)" annotation live on the targeted path —
|
||||
// approval edges arrive as state ticks now that child_ws_state no
|
||||
// longer takes the full render.
|
||||
_refreshChildrenCount();
|
||||
} else {
|
||||
renderChildren();
|
||||
}
|
||||
}
|
||||
|
||||
function _refreshChildrenCount() {
|
||||
const total = childrenState.size;
|
||||
const pending = pendingApprovalIds.size;
|
||||
childrenCountEl.textContent = total
|
||||
? "(" + total + (pending > 0 ? " · " + pending + " pending" : "") + ")"
|
||||
: "";
|
||||
}
|
||||
|
||||
function renderTaskRow(task) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "task-row";
|
||||
@@ -3855,6 +3901,12 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
ws_id: childId,
|
||||
name: "",
|
||||
};
|
||||
// Terminal-bucket membership BEFORE the mutation: the tree sort keys on
|
||||
// it (non-terminal first), so a state tick that crosses the boundary
|
||||
// needs the full re-sorting render; everything else takes the targeted
|
||||
// single-row path below.
|
||||
const wasTerminal =
|
||||
existing.state === "closed" || existing.state === "deleted";
|
||||
existing.state = ev.state || existing.state;
|
||||
existing.activity_state =
|
||||
typeof ev.activity_state === "string"
|
||||
@@ -3924,7 +3976,18 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
? cached.sseUpdatedAt || 0
|
||||
: 0,
|
||||
});
|
||||
renderChildren();
|
||||
// child_ws_state is the HIGHEST-frequency child event (a tick per state/
|
||||
// activity change of every child) — route it through the targeted
|
||||
// single-row update instead of the full-tree rebuild. The full render
|
||||
// (sort + replaceChildren + observer re-observe of every row) is
|
||||
// reserved for membership/sort-order changes: a terminal-bucket
|
||||
// crossing here, and created/closed/rename in their own handlers.
|
||||
// _updateChildRow falls back to renderChildren() itself when the row
|
||||
// isn't painted yet (a brand-new child).
|
||||
const isTerminal =
|
||||
existing.state === "closed" || existing.state === "deleted";
|
||||
if (wasTerminal !== isTerminal) renderChildren();
|
||||
else _updateChildRow(childId);
|
||||
// Do NOT invalidateLiveBadge on routine state ticks — that
|
||||
// defeats the 5s TTL cache and devolves rate-limiting to the
|
||||
// 250ms debouncer. The TTL check in scheduleLiveFetch handles
|
||||
|
||||
@@ -3233,20 +3233,23 @@ h3.skill-spec-heading {
|
||||
/* ==========================================================================
|
||||
OIDC detail panel (inline expansion below user row)
|
||||
========================================================================== */
|
||||
.oidc-detail-panel {
|
||||
.oidc-detail-panel,
|
||||
.proj-detail-panel {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 150ms ease;
|
||||
margin: 0 8px 0 24px;
|
||||
}
|
||||
.oidc-detail-inner {
|
||||
.oidc-detail-inner,
|
||||
.proj-detail-inner {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--row-alt);
|
||||
}
|
||||
.oidc-detail-header {
|
||||
.oidc-detail-header,
|
||||
.proj-detail-header {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
@@ -3254,10 +3257,46 @@ h3.skill-spec-heading {
|
||||
color: var(--fg-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.oidc-detail-header::before {
|
||||
.oidc-detail-header::before,
|
||||
.proj-detail-header::before {
|
||||
content: "\25c6 ";
|
||||
color: var(--accent);
|
||||
}
|
||||
/* Per-project resources panel rows: name/link left, dim meta right. */
|
||||
.proj-detail-header + .proj-detail-header,
|
||||
.proj-detail-row + .proj-detail-header {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.proj-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.proj-detail-row + .proj-detail-row {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.proj-detail-main {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.proj-detail-dim {
|
||||
color: var(--fg-dim);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.proj-detail-link {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border);
|
||||
}
|
||||
.proj-detail-link:hover {
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
.oidc-identity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 100px 1fr 60px 50px;
|
||||
@@ -3300,7 +3339,8 @@ h3.skill-spec-heading {
|
||||
.oidc-identity-actions .admin-btn-danger {
|
||||
font-size: 11px;
|
||||
}
|
||||
.oidc-detail-empty {
|
||||
.oidc-detail-empty,
|
||||
.proj-detail-empty {
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
@@ -3340,7 +3380,8 @@ h3.skill-spec-heading {
|
||||
.oidc-identity-time {
|
||||
display: none;
|
||||
}
|
||||
.oidc-detail-panel {
|
||||
.oidc-detail-panel,
|
||||
.proj-detail-panel {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
@@ -3540,6 +3581,7 @@ h3.skill-spec-heading {
|
||||
animation: none;
|
||||
}
|
||||
.oidc-detail-panel,
|
||||
.proj-detail-panel,
|
||||
.admin-expand-indicator {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -253,6 +253,194 @@ def user_can_access_project(
|
||||
return acc.can_write if write else acc.can_read
|
||||
|
||||
|
||||
class WorkstreamProjectVisibility:
|
||||
"""Per-request memoized visibility predicate for project-scoped workstreams.
|
||||
|
||||
Answers "may *user_id* see a workstream attached to *project_id*?" for
|
||||
listing filters and the row-access gate. Distinct from
|
||||
:func:`user_can_access_project` on purpose: that composes the RBAC
|
||||
capability (``project.read``, admin-default) with the ACL and gates the
|
||||
project *management* surfaces, whereas workstream visibility is a
|
||||
tenancy question — an explicit ``project_members`` row (or ownership)
|
||||
IS the grant, no capability required, or members without ``project.read``
|
||||
would lose sight of their own shared workstreams.
|
||||
|
||||
Rules (first match wins):
|
||||
|
||||
* ``bypass`` instances see everything — service-scope callers (the
|
||||
collector and other cluster machinery must never be blinded at the
|
||||
node edge; user-facing filtering happens at the console edge) and
|
||||
holders of ``admin.cluster.inspect`` (the existing cluster-wide
|
||||
workstream-inspect surface).
|
||||
* No / dangling ``project_id`` → visible (a deleted project leaves the
|
||||
link behind by design — no row, no privacy to enforce).
|
||||
* Non-private visibility → visible (trusted-team default).
|
||||
* Private → the workstream's own creator, the project owner, or a
|
||||
project member; anonymous callers fail closed.
|
||||
* A storage failure while resolving a project fails closed (treated
|
||||
as private-and-not-a-member) rather than leaking on a blip.
|
||||
|
||||
Project rows and membership verdicts are memoized per instance —
|
||||
construct one per request/connection, not per row.
|
||||
"""
|
||||
|
||||
def __init__(self, user_id: str, *, bypass: bool = False, storage: Any = None) -> None:
|
||||
self._user_id = user_id or ""
|
||||
self._bypass = bypass
|
||||
self._storage = storage
|
||||
self._projects: dict[str, dict[str, Any] | None] = {}
|
||||
self._member: dict[str, bool] = {}
|
||||
|
||||
@classmethod
|
||||
def for_request(cls, request: Any, *, storage: Any = None) -> WorkstreamProjectVisibility:
|
||||
"""Build a filter for an HTTP request's authenticated principal.
|
||||
|
||||
Service-scoped tokens and ``admin.cluster.inspect`` holders get a
|
||||
bypass instance; everyone else filters as themselves.
|
||||
"""
|
||||
auth: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
uid = str(getattr(auth, "user_id", "") or "")
|
||||
bypass = bool(
|
||||
auth is not None
|
||||
and (auth.has_scope("service") or auth.has_permission("admin.cluster.inspect"))
|
||||
)
|
||||
return cls(uid, bypass=bypass, storage=storage)
|
||||
|
||||
@property
|
||||
def bypass(self) -> bool:
|
||||
"""True when this principal sees everything (service scope /
|
||||
``admin.cluster.inspect``) — callers that transform payloads
|
||||
(not just drop rows) use this to leave them untouched."""
|
||||
return self._bypass
|
||||
|
||||
def _resolve_storage(self) -> Any:
|
||||
if self._storage is None:
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
self._storage = get_storage()
|
||||
return self._storage
|
||||
|
||||
def _project(self, project_id: str) -> dict[str, Any] | None:
|
||||
if project_id not in self._projects:
|
||||
storage = self._resolve_storage()
|
||||
if storage is None:
|
||||
raise RuntimeError("storage unavailable for project visibility check")
|
||||
self._projects[project_id] = storage.get_project(project_id)
|
||||
return self._projects[project_id]
|
||||
|
||||
def _is_member(self, project_id: str) -> bool:
|
||||
if project_id not in self._member:
|
||||
storage = self._resolve_storage()
|
||||
self._member[project_id] = bool(
|
||||
storage is not None and storage.is_project_member(project_id, self._user_id)
|
||||
)
|
||||
return self._member[project_id]
|
||||
|
||||
def _project_grants(self, project: dict[str, Any]) -> bool:
|
||||
"""The tenancy rule for one already-fetched project row.
|
||||
|
||||
THE single statement of who may see a project's workstreams —
|
||||
:meth:`ws_visibility` and :func:`ensure_project_attachable` both
|
||||
route through here so the security decision cannot diverge.
|
||||
"""
|
||||
if (project.get("visibility") or "private") != "private":
|
||||
return True
|
||||
if not self._user_id:
|
||||
return False
|
||||
if project.get("owner_id") == self._user_id:
|
||||
return True
|
||||
return self._is_member(str(project.get("project_id") or ""))
|
||||
|
||||
def ws_visibility(self, project_id: str | None, ws_owner: str = "") -> bool | None:
|
||||
"""Tri-state form of :meth:`ws_visible`.
|
||||
|
||||
``True`` visible, ``False`` denied, ``None`` undetermined — the
|
||||
project could not be resolved (storage failure). Callers that
|
||||
can retry later (the SSE event filter) use this to avoid pinning
|
||||
a verdict on a transient blip; everything else goes through
|
||||
:meth:`ws_visible`, which maps ``None`` to fail-closed.
|
||||
"""
|
||||
if self._bypass:
|
||||
return True
|
||||
# Only a real string can name a project — anything else (None,
|
||||
# a test double, a corrupted row) means "no project link", not
|
||||
# "private". Keeps the undetermined/fail-closed branch for
|
||||
# genuine lookup failures rather than type noise.
|
||||
if not project_id or not isinstance(project_id, str):
|
||||
return True
|
||||
pid = project_id.strip()
|
||||
if not pid:
|
||||
return True
|
||||
if ws_owner and ws_owner == self._user_id:
|
||||
return True
|
||||
try:
|
||||
project = self._project(pid)
|
||||
if project is None:
|
||||
return True
|
||||
return self._project_grants(project)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"workstream project-visibility check undetermined user=%s project=%s",
|
||||
self._user_id,
|
||||
pid,
|
||||
)
|
||||
return None
|
||||
|
||||
def ws_visible(self, project_id: str | None, ws_owner: str = "") -> bool:
|
||||
"""Apply the class rules to one workstream row (fail-closed).
|
||||
|
||||
An undetermined verdict (storage failure) hides the row rather
|
||||
than leaking on a blip.
|
||||
"""
|
||||
return self.ws_visibility(project_id, ws_owner=ws_owner) is True
|
||||
|
||||
|
||||
def ensure_project_attachable(
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
*,
|
||||
storage: Any = None,
|
||||
) -> tuple[int, str] | None:
|
||||
"""Gate attaching a NEW workstream to *project_id* at create time.
|
||||
|
||||
Returns ``None`` when the attach is allowed, else ``(status_code,
|
||||
message)`` for the handler to surface. Stricter than
|
||||
:meth:`WorkstreamProjectVisibility.ws_visible` in one way — a
|
||||
nonexistent project is a 400 (a dangling link on an EXISTING row is
|
||||
tolerated because project deletion leaves links behind, but minting
|
||||
a fresh dangling link is a caller error) — and shares its tenancy
|
||||
rule: private projects accept workstreams only from their owner or
|
||||
members; public/active-or-archived projects accept from anyone
|
||||
(memory writes stay member-gated at the session layer).
|
||||
|
||||
Fail-closed: empty ``user_id`` or a storage failure denies with 403.
|
||||
"""
|
||||
pid = (project_id or "").strip()
|
||||
if not pid:
|
||||
return None
|
||||
if storage is None:
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
if storage is None:
|
||||
return (403, "project access could not be verified")
|
||||
try:
|
||||
project = storage.get_project(pid)
|
||||
if project is None:
|
||||
return (400, "unknown project_id")
|
||||
# One tenancy rule, one place: reuse the visibility predicate's
|
||||
# core (same-module private access; the fetched row is seeded
|
||||
# into the memo so this costs no second get_project).
|
||||
vis = WorkstreamProjectVisibility(user_id, storage=storage)
|
||||
vis._projects[pid] = project
|
||||
if vis._project_grants(project):
|
||||
return None
|
||||
return (403, "cannot attach a workstream to a private project you don't belong to")
|
||||
except Exception:
|
||||
log.warning("project attach check failed user=%s project=%s — failing closed", user_id, pid)
|
||||
return (403, "project access could not be verified")
|
||||
|
||||
|
||||
def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
|
||||
"""Derive legacy scopes from a granular permission set."""
|
||||
scopes: set[str] = set()
|
||||
|
||||
@@ -99,7 +99,12 @@ def _build_openai_json(storage: StorageBackend, ws_id: str) -> bytes:
|
||||
if (part := attachment_to_content_part(att)) is not None
|
||||
}
|
||||
|
||||
repaired = repair_wire_messages(dicts_from_turns(storage.load_message_turns(ws_id)))
|
||||
# Export the FULL transcript, not the resume-only checkpoint view: a compacted
|
||||
# workstream must export its complete pre-compaction history (markers dropped),
|
||||
# never the bounded [summary]+[tail] slice load_message_turns returns by default.
|
||||
repaired = repair_wire_messages(
|
||||
dicts_from_turns(storage.load_message_turns(ws_id, checkpointed=False))
|
||||
)
|
||||
dicts = materialize_attachments(repaired, _resolve)
|
||||
messages = sanitize_messages(_attach_reasoning_content(dicts))
|
||||
return json.dumps({"messages": messages}, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
|
||||
+254
-53
@@ -31,6 +31,7 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import mcp.types as mcp_types
|
||||
from mcp import ClientSession, McpError, StdioServerParameters
|
||||
@@ -145,6 +146,93 @@ _MAX_PROMPTS_PER_SERVER = 1000
|
||||
_PRIME_MAX_CONCURRENCY = 4
|
||||
|
||||
|
||||
# ``mcp.client.streamable_http._send_session_terminated_error`` synthesizes this
|
||||
# (non-standard, POSITIVE) JSON-RPC code *client-side* when a held
|
||||
# ``mcp-session-id`` 404s after the MCP server restarted and dropped its session
|
||||
# map. It is NOT a documented MCP constant, and the SDK deliberately discards the
|
||||
# server's own 404 body ("Session not found", code ``-32600``) — so it is pinned
|
||||
# here, greppable for the next SDK bump. No spec-compliant server emits a POSITIVE
|
||||
# 32600, which is what makes the code a safe, deterministic dead-transport signal
|
||||
# to key off. The application-controlled message is NOT matched: a healthy
|
||||
# session-owning server can legitimately return a protocol error whose message is
|
||||
# "Session terminated".
|
||||
_SDK_SESSION_TERMINATED_CODE = 32600
|
||||
|
||||
|
||||
def _is_dead_transport(exc: BaseException) -> bool:
|
||||
"""True when *exc* means the MCP session's transport is dead and the
|
||||
session must be torn down and rebuilt (vs a protocol-level rejection
|
||||
from a still-healthy connection).
|
||||
|
||||
The streamable-http SDK holds the session over anyio in-memory streams.
|
||||
Three distinct death modes all mean "reconnect me", not "the server
|
||||
rejected my request":
|
||||
|
||||
1. **Local stream torn down** — the GET/SSE or POST stream died (idle close,
|
||||
peer reset, keep-alive expiry) and the ``ClientSession`` object survives
|
||||
with a closed write stream, so ``list_tools``/``call_tool`` raises
|
||||
:class:`anyio.ClosedResourceError` / :class:`anyio.BrokenResourceError`.
|
||||
2. **Transport-swallowed** — the SDK's ``post_writer`` swallows the upstream
|
||||
error and the caller sees ``McpError(CONNECTION_CLOSED)`` (-32000); or the
|
||||
underlying httpx connection is gone / unrecoverable mid-exchange. Every
|
||||
``httpx.NetworkError`` ({Connect,Read,Write,Close}Error), the Connect/Read/
|
||||
Write timeouts, and ``httpx.RemoteProtocolError`` qualify — notably a
|
||||
read/idle timeout on a long-lived stream, which is NOT a builtin
|
||||
``TimeoutError`` and would otherwise be misread as a healthy "other"
|
||||
failure. ``httpx.PoolTimeout`` is deliberately EXCLUDED: it means
|
||||
connection-pool saturation, not a dead connection — evicting the session
|
||||
wouldn't relieve the pressure and would trip the shared breaker for all
|
||||
users on transient load.
|
||||
3. **Server-side session lost** — the MCP server RESTARTED and dropped its
|
||||
session map, so our held ``mcp-session-id`` is unknown. The server returns
|
||||
HTTP 404 and the SDK synthesizes ``McpError(code=32600, "Session
|
||||
terminated")`` (see ``streamable_http._send_session_terminated_error``),
|
||||
discarding the server's own 404 body. Keyed off that synthesized code
|
||||
ALONE (a positive 32600 no compliant server emits); the message is
|
||||
application-controlled, so a healthy session-owning server that returns a
|
||||
protocol error reading "Session terminated" stays breaker-safe.
|
||||
|
||||
The raw-socket variants (``BrokenPipeError`` / ``ConnectionResetError`` /
|
||||
``EOFError``) are kept for the stdio transport and defense-in-depth.
|
||||
"""
|
||||
if isinstance(
|
||||
exc,
|
||||
anyio.ClosedResourceError
|
||||
| anyio.BrokenResourceError
|
||||
| BrokenPipeError
|
||||
| ConnectionResetError
|
||||
| EOFError
|
||||
# NetworkError == {Connect,Read,Write,Close}Error (connection gone). The
|
||||
# Connect/Read/Write timeouts mean a dead/hung connection; PoolTimeout is
|
||||
# EXCLUDED (pool saturation, not a dead session — eviction can't relieve
|
||||
# it and would trip the shared breaker under load). RemoteProtocolError
|
||||
# (peer broke framing) is dead; LocalProtocolError (our bug) stays out.
|
||||
| httpx.NetworkError
|
||||
| httpx.ConnectTimeout
|
||||
| httpx.ReadTimeout
|
||||
| httpx.WriteTimeout
|
||||
| httpx.RemoteProtocolError,
|
||||
):
|
||||
return True
|
||||
if isinstance(exc, McpError):
|
||||
err = exc.error
|
||||
code = getattr(err, "code", None)
|
||||
if code == mcp_types.CONNECTION_CLOSED:
|
||||
return True
|
||||
# Server-restarted-session loss: match the SDK's deterministic synthesized
|
||||
# code ALONE. The message is application-controlled — a healthy
|
||||
# session-owning server (e.g. a game/shell MCP server) can legitimately
|
||||
# reject a stale id with a protocol error whose message is
|
||||
# "Session terminated" / "session not found", and matching on it would
|
||||
# tear down that live session and trip the shared per-server breaker for
|
||||
# every user. The client never sees those messages for a REAL dead
|
||||
# transport (the SDK discards the server's 404 body and synthesizes code
|
||||
# 32600), so keying off the code loses no coverage.
|
||||
if code == _SDK_SESSION_TERMINATED_CODE:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AuthCapture:
|
||||
"""Carrier populated by the response hook on 4xx upstream responses."""
|
||||
@@ -1722,16 +1810,20 @@ class MCPClientManager:
|
||||
async def _prime_user_pools(self, user_id: str) -> None:
|
||||
"""Warm THIS user's consented ``oauth_user`` pools (runs on the mcp-loop).
|
||||
|
||||
Best-effort and NON-DESTRUCTIVE: each token is read directly (NOT via the
|
||||
refresh state machine) and missing/near-expiry tokens are skipped, so a
|
||||
transient AS/network failure during priming can never delete a token and
|
||||
force re-consent — a refresh that may fail belongs on the lazy dispatch
|
||||
path, driven by actual use. Servers are primed concurrently under
|
||||
``_PRIME_MAX_CONCURRENCY`` so one slow/unreachable upstream can't stall
|
||||
the rest.
|
||||
Best-effort and transient-safe: each token is resolved via the SAME
|
||||
guarded refresh state machine the lazy-dispatch path uses
|
||||
(:func:`get_user_access_token_classified`). That refreshes an expired /
|
||||
near-expiry access token and persists it, but a TRANSIENT AS/network
|
||||
failure keeps the token (``kind=refresh_failed_transient``, no revoke)
|
||||
and is simply skipped here — only a genuinely PERMANENT rejection (the
|
||||
user must re-consent anyway) clears it. This closes the chicken-and-egg
|
||||
where an expired token made priming skip the server, its tools never
|
||||
entered the per-user catalog, and lazy dispatch — the only OTHER refresh
|
||||
trigger — could therefore never fire, leaving the pool permanently cold
|
||||
and stuck "connecting" with no token. Servers are primed concurrently
|
||||
under ``_PRIME_MAX_CONCURRENCY`` so one slow/unreachable upstream can't
|
||||
stall the rest.
|
||||
"""
|
||||
from turnstone.core.mcp_oauth import _token_needs_refresh
|
||||
|
||||
token_store = getattr(self._app_state, "mcp_token_store", None)
|
||||
if token_store is None:
|
||||
return
|
||||
@@ -1750,22 +1842,30 @@ class MCPClientManager:
|
||||
try:
|
||||
async with sem:
|
||||
try:
|
||||
# Non-refreshing read — priming must not drive a refresh
|
||||
# whose transient failure would revoke the token.
|
||||
plain = await asyncio.to_thread(
|
||||
token_store.get_user_token, user_id, server_name
|
||||
# Resolve via the guarded refresh state machine, but with
|
||||
# revoke_ambiguous_escalation=False: a genuinely-dead grant
|
||||
# (permanent AS rejection / expired-no-refresh) is still
|
||||
# revoked so the catalog isn't left cold behind a phantom
|
||||
# "consented" token, but a sustained-UNCLASSIFIABLE rejection
|
||||
# is deferred to lazy dispatch — priming runs for every
|
||||
# consented server, so an AS hiccup we can't classify must
|
||||
# not revoke consent for one the user may not be using this
|
||||
# session. Only kind == "token" warms the pool.
|
||||
lookup = await get_user_access_token_classified(
|
||||
app_state=self._app_state,
|
||||
user_id=user_id,
|
||||
server_name=server_name,
|
||||
revoke_ambiguous_escalation=False,
|
||||
)
|
||||
if plain is None or not plain.get("access_token"):
|
||||
return # no usable token (not consented) — lazy paths handle it
|
||||
if _token_needs_refresh(plain.get("expires_at")):
|
||||
return # near expiry — let lazy dispatch refresh on actual use
|
||||
if lookup.kind != "token" or not lookup.token:
|
||||
return # not consented / undecryptable / refresh failed — lazy paths handle it
|
||||
server_row = await asyncio.to_thread(
|
||||
self._storage.get_mcp_server_by_name, server_name
|
||||
)
|
||||
if not server_row:
|
||||
return
|
||||
cfg = _pool_cfg_from_row(server_row)
|
||||
await self._prime_user_server(key, cfg, plain["access_token"])
|
||||
await self._prime_user_server(key, cfg, lookup.token)
|
||||
log.info(
|
||||
"mcp pool auto-primed at session start user=%s server=%s",
|
||||
user_id,
|
||||
@@ -1948,9 +2048,15 @@ class MCPClientManager:
|
||||
return "auth_401"
|
||||
if status == 403:
|
||||
return "auth_403"
|
||||
# A closed/broken transport must be classified BEFORE the McpError
|
||||
# branch: the SDK surfaces a dead connection as McpError(CONNECTION_CLOSED),
|
||||
# which would otherwise be mistaken for a healthy protocol rejection and
|
||||
# leave the dead pool entry in place forever (no rebuild).
|
||||
if _is_dead_transport(exc):
|
||||
return "transport"
|
||||
if isinstance(exc, McpError):
|
||||
return "protocol"
|
||||
if isinstance(exc, BrokenPipeError | ConnectionResetError | EOFError | TimeoutError):
|
||||
if isinstance(exc, TimeoutError):
|
||||
return "transport"
|
||||
return "other"
|
||||
|
||||
@@ -2434,6 +2540,17 @@ class MCPClientManager:
|
||||
log.warning("Refresh failed for MCP server '%s'", name, exc_info=True)
|
||||
self._set_error(name, f"Refresh failed: {exc}")
|
||||
results[name] = ([], [])
|
||||
# A dead transport leaves a non-None but unusable session, so
|
||||
# the reconnect branch at the top of this loop (gated on
|
||||
# ``session is None``) would never fire and we would re-probe
|
||||
# the corpse on every tick forever — the exact failure that
|
||||
# required a full process restart to clear. Evicting the
|
||||
# session here makes the NEXT refresh tick reconnect, turning
|
||||
# this periodic refresh into a self-healing liveness probe.
|
||||
if _is_dead_transport(exc):
|
||||
dead_state = self._static_servers.get(name)
|
||||
if dead_state is not None:
|
||||
dead_state.session = None
|
||||
# Overwrite unconditionally with the freshest observed
|
||||
# outcome. Two cases produce the write:
|
||||
# (1) Reconnect branch: ``_connect_one`` raised before
|
||||
@@ -3254,8 +3371,23 @@ class MCPClientManager:
|
||||
clean = msg.replace("\n", " ").replace("\r", "")
|
||||
self._last_error[name] = clean[: self._MAX_ERROR_LEN]
|
||||
|
||||
def get_server_status(self, name: str) -> dict[str, Any]:
|
||||
"""Return live status for a single server, including config details."""
|
||||
def get_server_status(
|
||||
self, name: str, user_id: str | None = None, *, aggregate: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Return live status for a single server, including config details.
|
||||
|
||||
For ``auth_type='oauth_user'`` servers the result is scoped to *user_id*,
|
||||
or aggregated across all users when ``aggregate=True`` (see
|
||||
:meth:`_oauth_user_server_status`). Both are ignored for static servers,
|
||||
whose session is process-global.
|
||||
"""
|
||||
# auth_type='oauth_user' servers hold NO process-global session — they
|
||||
# are warmed per-user into the pool — so the static-session check below
|
||||
# would always report them "connecting". Derive their status from the
|
||||
# REQUESTING user's warm pool entry instead, so the console pill reflects
|
||||
# that user's real reachability once their pool is primed.
|
||||
if name in self._oauth_user_server_names:
|
||||
return self._oauth_user_server_status(name, user_id, aggregate=aggregate)
|
||||
state = self._static_servers.get(name)
|
||||
connected = state is not None and state.session is not None
|
||||
cfg = self._server_configs.get(name, {})
|
||||
@@ -3284,11 +3416,84 @@ class MCPClientManager:
|
||||
"last_refresh_outcome": last_refresh[1] if last_refresh is not None else None,
|
||||
}
|
||||
|
||||
def get_all_server_status(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return live status for all configured servers."""
|
||||
def _oauth_user_server_status(
|
||||
self, name: str, user_id: str | None, *, aggregate: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Live status for an ``auth_type='oauth_user'`` server, scoped to *user_id*.
|
||||
|
||||
These have no global session (stripped from ``_server_configs`` /
|
||||
``_static_servers``); they connect per-user into ``_user_pool_entries``.
|
||||
``connected`` and the catalog counts reflect ONLY the requesting user's
|
||||
warm pool entry — never another user's. The per-user pool is per-user
|
||||
data, and ``connected`` / ``tools`` / ``resources`` / ``prompts`` reach
|
||||
read-scoped callers over the wire, so deriving them from an arbitrary
|
||||
other user's pool would leak that user's catalog (and its existence) to
|
||||
anyone with read scope. A request with no user context (``user_id``
|
||||
falsy, e.g. an operator refresh/reconnect) reports ``connected=False``.
|
||||
|
||||
``aggregate=True`` is the admin cluster-health view: ``connected`` and a
|
||||
representative catalog count reflect ANY user's warm pool. It is gated at
|
||||
the endpoint on the ``admin.mcp`` permission, whose holders already see
|
||||
cross-user MCP state (consent counts, server config), so it is not a new
|
||||
disclosure — it restores the "in use by anyone" pill for operators
|
||||
without exposing one user's pool to another read-scoped user.
|
||||
"""
|
||||
# Snapshot with list(): the mcp-loop thread mutates _user_pool_entries
|
||||
# (prime insert / idle eviction) concurrently with status polls from the
|
||||
# console/server thread, and iterating a live dict that changes size
|
||||
# raises RuntimeError mid-comprehension. The sibling get_all_server_status
|
||||
# and the eviction loop snapshot the same way.
|
||||
entries = list(self._user_pool_entries.items())
|
||||
if aggregate:
|
||||
warm = [e for (uid, sname), e in entries if sname == name and e.session is not None]
|
||||
elif user_id:
|
||||
warm = [
|
||||
e
|
||||
for (uid, sname), e in entries
|
||||
if sname == name and uid == user_id and e.session is not None
|
||||
]
|
||||
else:
|
||||
warm = []
|
||||
# rep is the first warm entry (insertion order): the requester's own pool
|
||||
# when scoped, or a representative catalog for the aggregate operator view.
|
||||
rep = warm[0] if warm else None
|
||||
cb_deadline = self._circuit_open_until.get(name)
|
||||
cb_open = cb_deadline is not None and time.monotonic() < cb_deadline
|
||||
last_refresh = self._last_refresh.get(name)
|
||||
return {
|
||||
"connected": bool(warm),
|
||||
"tools": len(rep.tools) if rep is not None and rep.tools else 0,
|
||||
"resources": len(rep.resources) if rep is not None and rep.resources else 0,
|
||||
"prompts": len(rep.prompts) if rep is not None and rep.prompts else 0,
|
||||
"error": self._last_error.get(name, ""),
|
||||
"transport": "streamable-http",
|
||||
"command": "",
|
||||
"url": "",
|
||||
"circuit_open": cb_open,
|
||||
"consecutive_failures": self._consecutive_failures.get(name, 0),
|
||||
"auth_type": "oauth_user",
|
||||
"user_pools": len(warm),
|
||||
"last_refresh_at": last_refresh[0] if last_refresh is not None else None,
|
||||
"last_refresh_outcome": last_refresh[1] if last_refresh is not None else None,
|
||||
}
|
||||
|
||||
def get_all_server_status(
|
||||
self, user_id: str | None = None, *, aggregate: bool = False
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Return live status for all configured servers.
|
||||
|
||||
Includes ``oauth_user`` servers (which are absent from
|
||||
``_server_configs``) so the console list reports their real per-user
|
||||
pool status instead of falling back to a DB-only "connecting" default.
|
||||
Their status is scoped to *user_id*, or aggregated across users when
|
||||
``aggregate=True`` (see :meth:`get_server_status`).
|
||||
"""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for name in list(self._server_configs):
|
||||
result[name] = self.get_server_status(name)
|
||||
result[name] = self.get_server_status(name, user_id, aggregate=aggregate)
|
||||
for name in list(self._oauth_user_server_names):
|
||||
if name not in result:
|
||||
result[name] = self.get_server_status(name, user_id, aggregate=aggregate)
|
||||
return result
|
||||
|
||||
def reconcile_sync(self, storage: Any, timeout: int = 30) -> dict[str, Any]:
|
||||
@@ -3643,6 +3848,28 @@ class MCPClientManager:
|
||||
self._loop.call_soon_threadsafe(_schedule_refresh)
|
||||
return session
|
||||
|
||||
def _record_and_evict_on_dead_transport(self, server_name: str, exc: BaseException) -> None:
|
||||
"""Shared static-dispatch failure handling for ``call_tool_sync`` /
|
||||
``read_resource_sync`` / ``get_prompt_sync`` (call from their ``except``,
|
||||
then re-raise).
|
||||
|
||||
Protocol errors (``McpError`` from a healthy connection that rejected the
|
||||
request) do NOT trip the breaker. A dead transport — anyio
|
||||
Closed/BrokenResourceError, the SDK-swallowed ``McpError(CONNECTION_CLOSED)``,
|
||||
a server-restarted session, or a gone httpx connection — IS a transport
|
||||
failure even when it is an ``McpError``, so it trips the breaker AND evicts
|
||||
the session (leaving stack/streams for ``_connect_one``'s stale-and-stack
|
||||
guard to reap). Eviction is what lets the next dispatch's ``session is
|
||||
None`` check fire ``_cb_auto_reconnect`` instead of re-using the corpse.
|
||||
"""
|
||||
dead = _is_dead_transport(exc)
|
||||
if dead or not isinstance(exc, McpError):
|
||||
self._cb_record_failure(server_name)
|
||||
if dead:
|
||||
evict = self._static_servers.get(server_name)
|
||||
if evict is not None:
|
||||
evict.session = None
|
||||
|
||||
def call_tool_sync(
|
||||
self,
|
||||
func_name: str,
|
||||
@@ -3720,17 +3947,7 @@ class MCPClientManager:
|
||||
self._cb_record_failure(server_name)
|
||||
raise TimeoutError(f"MCP tool call timed out after {timeout}s") from None
|
||||
except Exception as exc:
|
||||
# Protocol errors (McpError) come from a healthy connection that
|
||||
# rejected the request — only transport errors trip the breaker.
|
||||
if not isinstance(exc, McpError):
|
||||
self._cb_record_failure(server_name)
|
||||
if isinstance(exc, BrokenPipeError | ConnectionResetError | EOFError):
|
||||
# Evict the session only — leave stack/streams behind so the
|
||||
# stale-session-and-stack guard in _connect_one cleans them up
|
||||
# on the next connect attempt.
|
||||
evict = self._static_servers.get(server_name)
|
||||
if evict is not None:
|
||||
evict.session = None
|
||||
self._record_and_evict_on_dead_transport(server_name, exc)
|
||||
raise
|
||||
|
||||
self._cb_record_success(server_name)
|
||||
@@ -5130,15 +5347,7 @@ class MCPClientManager:
|
||||
self._cb_record_failure(server_name)
|
||||
raise TimeoutError(f"MCP resource read timed out after {timeout}s") from None
|
||||
except Exception as exc:
|
||||
if not isinstance(exc, McpError):
|
||||
self._cb_record_failure(server_name)
|
||||
if isinstance(exc, (BrokenPipeError, ConnectionResetError, EOFError)):
|
||||
# Evict the session only — leave stack/streams behind so the
|
||||
# stale-session-and-stack guard in _connect_one cleans them up
|
||||
# on the next connect attempt.
|
||||
evict = self._static_servers.get(server_name)
|
||||
if evict is not None:
|
||||
evict.session = None
|
||||
self._record_and_evict_on_dead_transport(server_name, exc)
|
||||
raise
|
||||
|
||||
self._cb_record_success(server_name)
|
||||
@@ -5232,15 +5441,7 @@ class MCPClientManager:
|
||||
self._cb_record_failure(server_name)
|
||||
raise TimeoutError(f"MCP prompt retrieval timed out after {timeout}s") from None
|
||||
except Exception as exc:
|
||||
if not isinstance(exc, McpError):
|
||||
self._cb_record_failure(server_name)
|
||||
if isinstance(exc, (BrokenPipeError, ConnectionResetError, EOFError)):
|
||||
# Evict the session only — leave stack/streams behind so the
|
||||
# stale-session-and-stack guard in _connect_one cleans them up
|
||||
# on the next connect attempt.
|
||||
evict = self._static_servers.get(server_name)
|
||||
if evict is not None:
|
||||
evict.session = None
|
||||
self._record_and_evict_on_dead_transport(server_name, exc)
|
||||
raise
|
||||
|
||||
self._cb_record_success(server_name)
|
||||
|
||||
+48
-11
@@ -1500,7 +1500,12 @@ async def get_user_access_token(*, app_state: Any, user_id: str, server_name: st
|
||||
|
||||
|
||||
async def get_user_access_token_classified(
|
||||
*, app_state: Any, user_id: str, server_name: str, force_refresh: bool = False
|
||||
*,
|
||||
app_state: Any,
|
||||
user_id: str,
|
||||
server_name: str,
|
||||
force_refresh: bool = False,
|
||||
revoke_ambiguous_escalation: bool = True,
|
||||
) -> TokenLookupResult:
|
||||
"""Tagged token lookup with refresh-on-expiry.
|
||||
|
||||
@@ -1531,6 +1536,19 @@ async def get_user_access_token_classified(
|
||||
callers still collapse to one round-trip via the dual-layer lock:
|
||||
the second caller sees ``last_refreshed > t_lock_request_started``
|
||||
and reuses the freshly-refreshed token.
|
||||
|
||||
``revoke_ambiguous_escalation=False`` narrows revocation for background
|
||||
session-start priming. A genuinely-dead grant is STILL revoked so it is
|
||||
cleaned up and the user gets a re-consent affordance: a PERMANENT AS
|
||||
rejection (``invalid_grant`` / ``invalid_scope`` — a reliable dead-grant
|
||||
signal per RFC 6749 §5.2) and an expired-with-no-refresh token both revoke
|
||||
unconditionally. Only the *sustained-ambiguous* escalation — an
|
||||
unclassifiable 400/401 the heuristic would treat as dead after a streak — is
|
||||
deferred: priming runs for EVERY consented server, so an unclassifiable AS
|
||||
hiccup must not revoke consent for a server the user may not even be using
|
||||
this session. The streak + cooldown persist, so the authoritative
|
||||
escalation-revoke happens on the lazy-dispatch path when the user actually
|
||||
invokes the tool.
|
||||
"""
|
||||
token_store: MCPTokenStore | None = getattr(app_state, "mcp_token_store", None)
|
||||
if token_store is None:
|
||||
@@ -1594,6 +1612,8 @@ async def get_user_access_token_classified(
|
||||
|
||||
refresh_value = plain.get("refresh_token")
|
||||
if not refresh_value:
|
||||
# Expired token with no refresh token: genuinely unusable, no
|
||||
# misclassification risk (a local check, not an AS response) — revoke.
|
||||
return await _revoke_after_refresh_failure(
|
||||
app_state,
|
||||
token_store,
|
||||
@@ -1672,7 +1692,10 @@ async def get_user_access_token_classified(
|
||||
except MCPOAuthRefreshFailed as exc:
|
||||
if exc.failure_class is _RefreshFailureClass.PERMANENT:
|
||||
# The AS rejected the grant as dead (invalid_grant / invalid_scope
|
||||
# / an OIDC interaction-required code) — revoke and re-consent.
|
||||
# / an OIDC interaction-required code): a reliable dead-grant
|
||||
# signal (RFC 6749 §5.2), so revoke unconditionally — even under
|
||||
# background priming. Deferring it would strand the catalog cold
|
||||
# with the token still reading "consented" and no re-consent path.
|
||||
return await _revoke_after_refresh_failure(
|
||||
app_state,
|
||||
token_store,
|
||||
@@ -1694,21 +1717,35 @@ async def get_user_access_token_classified(
|
||||
# non-standard shape. Escalate to re-consent so the user
|
||||
# isn't stranded on a retryable error forever. (Infra
|
||||
# transients never reach here, so an outage can't escalate.)
|
||||
if revoke_ambiguous_escalation:
|
||||
log.warning(
|
||||
"mcp_server.oauth.refresh_ambiguous_escalated",
|
||||
user_id=user_id,
|
||||
server_name=server_name,
|
||||
streak=backoff.ambiguous_streak,
|
||||
error=str(exc),
|
||||
)
|
||||
return await _revoke_after_refresh_failure(
|
||||
app_state,
|
||||
token_store,
|
||||
user_id,
|
||||
server_name,
|
||||
server_id_for_audit,
|
||||
reason="refresh_failed_ambiguous_escalated",
|
||||
)
|
||||
# Background priming: an UNCLASSIFIABLE sustained rejection is
|
||||
# exactly where a bulk prime of servers the user may not be
|
||||
# using must not revoke consent. Defer the escalation-revoke to
|
||||
# lazy dispatch — the streak + armed cooldown persist, so it
|
||||
# escalates on the user's next real call. Falls through to the
|
||||
# transient return below (token kept, lock retained).
|
||||
log.warning(
|
||||
"mcp_server.oauth.refresh_ambiguous_escalated",
|
||||
"mcp_server.oauth.refresh_ambiguous_escalation_deferred",
|
||||
user_id=user_id,
|
||||
server_name=server_name,
|
||||
streak=backoff.ambiguous_streak,
|
||||
error=str(exc),
|
||||
)
|
||||
return await _revoke_after_refresh_failure(
|
||||
app_state,
|
||||
token_store,
|
||||
user_id,
|
||||
server_name,
|
||||
server_id_for_audit,
|
||||
reason="refresh_failed_ambiguous_escalated",
|
||||
)
|
||||
else:
|
||||
# A clean infra/operator-fixable transient breaks any ambiguous
|
||||
# run — only an uninterrupted streak escalates.
|
||||
|
||||
+125
-38
@@ -13,8 +13,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
@@ -107,19 +105,35 @@ def load_messages(ws_id: str, *, repair: bool = True) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def load_message_turns(ws_id: str) -> list[Turn]:
|
||||
def load_message_turns(ws_id: str, *, checkpointed: bool = True) -> list[Turn]:
|
||||
"""Load a workstream's history as canonical ``Turn``s (by-reference content).
|
||||
|
||||
The resume path — see :meth:`StorageBackend.load_message_turns`. Returns an
|
||||
empty list on any storage error (a failed resume must not crash the session).
|
||||
|
||||
``checkpointed=True`` (resume default) returns the bounded ``[summary]+[tail]``
|
||||
view when a compaction marker exists; ``checkpointed=False`` returns the full
|
||||
transcript (markers dropped) for export/audit.
|
||||
"""
|
||||
try:
|
||||
return get_storage().load_message_turns(ws_id)
|
||||
return get_storage().load_message_turns(ws_id, checkpointed=checkpointed)
|
||||
except Exception:
|
||||
log.warning("Failed to load message turns for ws=%s", ws_id, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def get_compaction_watermark(ws_id: str, preserve_tail: int = 0) -> int | None:
|
||||
"""Boundary id for a compaction checkpoint marker — see
|
||||
:meth:`StorageBackend.get_compaction_watermark`. Returns ``None`` on any
|
||||
storage error (a failed watermark just skips the checkpoint write — the next
|
||||
reopen reloads more history, the pre-checkpoint behavior, rather than crash)."""
|
||||
try:
|
||||
return get_storage().get_compaction_watermark(ws_id, preserve_tail)
|
||||
except Exception:
|
||||
log.warning("Failed to get compaction watermark for ws=%s", ws_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# -- Workstream attachments ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -196,6 +210,34 @@ def attachment_referenced_in_ws(attachment_id: str, ws_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def count_messages(ws_id: str) -> int:
|
||||
"""Total conversation rows for ``ws_id`` (markers included).
|
||||
|
||||
Returns ``0`` on error — callers that truncate on this count (rewind/retry)
|
||||
must treat ``0`` as "unknown, do not delete" rather than "empty", so a
|
||||
transient count failure never turns into a wrong deletion.
|
||||
"""
|
||||
try:
|
||||
return get_storage().count_messages(ws_id)
|
||||
except Exception:
|
||||
log.warning("Failed to count messages for ws=%s", ws_id, exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def get_compaction_floor(ws_id: str) -> int:
|
||||
"""Rows backing the latest compaction summary that rewind/retry must keep —
|
||||
see :meth:`StorageBackend.get_compaction_floor`. Returns ``-1`` on error: a
|
||||
sentinel distinct from a legitimate ``0`` (never compacted), because a ``0``
|
||||
floor on a *compacted* ws would let an over-deep trim delete the summary's
|
||||
backing. Callers that floor a deletion on this (rewind/retry) MUST skip the
|
||||
delete when it is negative."""
|
||||
try:
|
||||
return get_storage().get_compaction_floor(ws_id)
|
||||
except Exception:
|
||||
log.warning("Failed to get compaction floor for ws=%s", ws_id, exc_info=True)
|
||||
return -1
|
||||
|
||||
|
||||
def delete_messages_after(ws_id: str, keep_count: int) -> int:
|
||||
"""Delete conversation rows beyond the first *keep_count* rows.
|
||||
|
||||
@@ -276,6 +318,7 @@ def list_workstreams_with_history(
|
||||
kind: WorkstreamKind | str | None = None,
|
||||
user_id: str | None = None,
|
||||
state: str | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[Any]:
|
||||
"""List workstreams that have conversation messages.
|
||||
|
||||
@@ -300,6 +343,7 @@ def list_workstreams_with_history(
|
||||
kind=kind,
|
||||
user_id=user_id,
|
||||
state=state,
|
||||
offset=offset,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to list workstreams with history", exc_info=True)
|
||||
@@ -572,6 +616,20 @@ def get_workstream_owner(ws_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_workstream_row(ws_id: str) -> dict[str, Any] | None:
|
||||
"""Return the full workstreams row dict, or None when missing/unreadable.
|
||||
|
||||
Same fail-soft shape as :func:`get_workstream_owner` — access gates
|
||||
treat ``None`` as not-found, so a storage blip degrades to a 404
|
||||
rather than a 500.
|
||||
"""
|
||||
try:
|
||||
return get_storage().get_workstream(ws_id)
|
||||
except Exception:
|
||||
log.warning("Failed to get workstream row ws=%s", ws_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def update_workstream_title(ws_id: str, title: str) -> None:
|
||||
"""Set or update the auto-generated title for a workstream."""
|
||||
try:
|
||||
@@ -583,19 +641,58 @@ def update_workstream_title(ws_id: str, title: str) -> None:
|
||||
# -- Conversation search -------------------------------------------------------
|
||||
|
||||
|
||||
def search_history(query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
"""Search conversation history."""
|
||||
def search_history(
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
exclude_ws_id: str | None = None,
|
||||
exclude_after: int | None = None,
|
||||
) -> list[Any]:
|
||||
"""Search conversation history.
|
||||
|
||||
``user_id`` scopes rows by project tenancy (private-project workstreams
|
||||
hidden unless creator/owner/member — see
|
||||
:meth:`StorageBackend.search_history`); ``None`` = unscoped, for
|
||||
single-user lanes only. ``exclude_ws_id``/``exclude_after`` drop the
|
||||
excluded workstream's live segment (rows above its compaction
|
||||
checkpoint; the whole workstream when ``exclude_after`` is ``None``) —
|
||||
the model-facing recall path passes its own ws so results never
|
||||
duplicate what is already in context.
|
||||
"""
|
||||
try:
|
||||
return get_storage().search_history(query, limit, offset)
|
||||
return get_storage().search_history(
|
||||
query,
|
||||
limit,
|
||||
offset,
|
||||
user_id=user_id,
|
||||
exclude_ws_id=exclude_ws_id,
|
||||
exclude_after=exclude_after,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to search history", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def search_history_recent(limit: int = 20) -> list[Any]:
|
||||
"""Return most recent conversation messages."""
|
||||
def get_compaction_checkpoint(ws_id: str) -> int | None:
|
||||
"""Latest persisted compaction marker's watermark for ``ws_id`` — see
|
||||
:meth:`StorageBackend.get_compaction_checkpoint`. Returns ``None`` on any
|
||||
storage error, which callers must read as "the whole workstream is live"
|
||||
(recall then excludes it entirely — degraded to less information, never
|
||||
to duplicated or leaked rows)."""
|
||||
try:
|
||||
return get_storage().search_history_recent(limit)
|
||||
return get_storage().get_compaction_checkpoint(ws_id)
|
||||
except Exception:
|
||||
log.warning("Failed to get compaction checkpoint for ws=%s", ws_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def search_history_recent(limit: int = 20, *, user_id: str | None = None) -> list[Any]:
|
||||
"""Return most recent conversation messages, tenancy-scoped like
|
||||
:func:`search_history`."""
|
||||
try:
|
||||
return get_storage().search_history_recent(limit, user_id=user_id)
|
||||
except Exception:
|
||||
log.warning("Failed to search recent history", exc_info=True)
|
||||
return []
|
||||
@@ -607,44 +704,34 @@ def search_history_recent(limit: int = 20) -> list[Any]:
|
||||
def save_structured_memory(
|
||||
name: str,
|
||||
content: str,
|
||||
description: str = "",
|
||||
mem_type: str = "general",
|
||||
description: str | None = None,
|
||||
mem_type: str | None = None,
|
||||
scope: str = "global",
|
||||
scope_id: str = "",
|
||||
) -> tuple[str, str | None]:
|
||||
"""Save a structured memory (upsert by name+scope+scope_id).
|
||||
) -> tuple[dict[str, str] | None, bool]:
|
||||
"""Save a structured memory as a single atomic upsert by name+scope+scope_id.
|
||||
|
||||
Returns (memory_id, old_content_or_None). Uses create-first to
|
||||
avoid TOCTOU races under concurrent access.
|
||||
Returns ``(row, was_update)`` where ``row`` is the full saved record (or
|
||||
``None`` on failure). The write is exactly one
|
||||
``INSERT ... ON CONFLICT DO UPDATE ... RETURNING`` statement (see
|
||||
:meth:`StorageBackend.upsert_structured_memory`) -- no preceding read, no
|
||||
IntegrityError round-trip, no TOCTOU window. ``(row, was_update)`` comes
|
||||
straight from that upsert (this passes a fresh ``memory_id``, so a differing
|
||||
returned id means an existing row was updated in place). A ``None``
|
||||
description / ``mem_type`` means "leave unset" -- the column default applies
|
||||
on insert and the stored value is kept on conflict.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
name = normalize_key(name)
|
||||
try:
|
||||
storage = get_storage()
|
||||
# Try create first — if it hits the unique constraint, fall back to update
|
||||
memory_id = str(uuid.uuid4())
|
||||
try:
|
||||
storage.create_structured_memory(
|
||||
memory_id, name, description, mem_type, scope, scope_id, content
|
||||
)
|
||||
return memory_id, None
|
||||
except sa.exc.IntegrityError:
|
||||
# Unique constraint violation — row already exists, update it
|
||||
existing = storage.get_structured_memory_by_name(name, scope, scope_id)
|
||||
if existing:
|
||||
old_content = existing["content"]
|
||||
updates: dict[str, str] = {"content": content}
|
||||
if description:
|
||||
updates["description"] = description
|
||||
if mem_type != "general":
|
||||
updates["type"] = mem_type
|
||||
storage.update_structured_memory(existing["memory_id"], **updates)
|
||||
return existing["memory_id"], old_content
|
||||
return "", None
|
||||
row, was_update = get_storage().upsert_structured_memory(
|
||||
str(uuid.uuid4()), name, description, mem_type, scope, scope_id, content
|
||||
)
|
||||
return (row, was_update) if row else (None, False)
|
||||
except Exception:
|
||||
log.warning("Failed to save structured memory name=%s", name, exc_info=True)
|
||||
return "", None
|
||||
return None, False
|
||||
|
||||
|
||||
def get_structured_memory_by_name(
|
||||
|
||||
@@ -110,6 +110,24 @@ NUDGE_REPEAT = (
|
||||
"tool, different arguments, or ask the user for clarification."
|
||||
)
|
||||
|
||||
NUDGE_COMPACTION = (
|
||||
"The conversation is approaching the context limit and will be compacted "
|
||||
"shortly — older messages will be replaced by a summary. Reach a natural "
|
||||
"stopping point. Before continuing, record in this turn your current goal, "
|
||||
"the tasks that remain, and your intended next steps; anything not written "
|
||||
"down here may be lost when compaction runs. If you can give a final answer "
|
||||
"now, do so — otherwise state clearly where to resume."
|
||||
)
|
||||
|
||||
NUDGE_COMPACTION_RESUME = (
|
||||
"The conversation was just compacted to free context. If there is remaining "
|
||||
"work, continue from the summary above — pick up the open tasks and next "
|
||||
"steps you recorded and keep going without waiting for further instructions. "
|
||||
"The summary is a digest, not the record: if it is missing a detail you "
|
||||
"need, the recall tool can search the compacted portion of this "
|
||||
"conversation. If the task is already complete, give your final answer."
|
||||
)
|
||||
|
||||
_NUDGE_MAP: dict[str, str] = {
|
||||
"correction": NUDGE_CORRECTION,
|
||||
"denial": NUDGE_DENIAL,
|
||||
@@ -118,6 +136,7 @@ _NUDGE_MAP: dict[str, str] = {
|
||||
"start": NUDGE_START,
|
||||
"tool_error": NUDGE_TOOL_ERROR,
|
||||
"repeat": NUDGE_REPEAT,
|
||||
"compaction_pending": NUDGE_COMPACTION,
|
||||
# idle_children and watch_triggered carry no static body — the
|
||||
# per-fire text comes from a producer (``format_idle_children_nudge``
|
||||
# for the former, ``format_watch_message`` + ``sanitize_payload``
|
||||
|
||||
+1673
-374
File diff suppressed because it is too large
Load Diff
@@ -1540,6 +1540,18 @@ def make_retry_handler(
|
||||
ui._enqueue({"type": "busy_error", "message": "Cannot retry while processing."})
|
||||
return JSONResponse({"status": "busy"})
|
||||
|
||||
# A retry is a fresh turn initiated by the authenticated caller —
|
||||
# rebind per-user MCP credential resolution to them before the
|
||||
# re-send dispatches (the per-kind ``dispatch_retry`` closure
|
||||
# calls ``send()`` without identity kwargs). getattr-guarded so
|
||||
# per-kind session stubs without the method keep working.
|
||||
from turnstone.core.web_helpers import auth_user_id
|
||||
|
||||
acting_uid = auth_user_id(request)
|
||||
bind_acting = getattr(session, "bind_acting_user", None)
|
||||
if acting_uid and callable(bind_acting):
|
||||
bind_acting(acting_uid)
|
||||
|
||||
retry_msg = session.retry()
|
||||
|
||||
if hasattr(ui, "_enqueue"):
|
||||
@@ -2636,12 +2648,21 @@ def make_list_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
resolve_titles = cfg.list_resolve_titles
|
||||
|
||||
def _build_rows() -> list[dict[str, Any]]:
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
|
||||
visibility = WorkstreamProjectVisibility.for_request(request)
|
||||
wss = mgr.list_all()
|
||||
titles: dict[str, str | None] = {}
|
||||
if resolve_titles is not None and wss:
|
||||
titles = resolve_titles([ws.id for ws in wss])
|
||||
rows: list[dict[str, Any]] = []
|
||||
for ws in wss:
|
||||
raw_pid = getattr(ws, "project_id", "")
|
||||
project_id = raw_pid if isinstance(raw_pid, str) else ""
|
||||
# Private-project tenancy — drop rows the requester may
|
||||
# not see (same predicate as the saved list).
|
||||
if not visibility.ws_visible(project_id, ws_owner=ws.user_id or ""):
|
||||
continue
|
||||
title = titles.get(ws.id) or ws.name
|
||||
rows.append(
|
||||
{
|
||||
@@ -2651,6 +2672,7 @@ def make_list_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
"kind": ws.kind,
|
||||
"parent_ws_id": ws.parent_ws_id,
|
||||
"user_id": ws.user_id,
|
||||
"project_id": project_id or None,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
@@ -2682,15 +2704,54 @@ async def _collect_saved_rows(
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
from turnstone.core.memory import list_workstreams_with_history
|
||||
|
||||
rows = await asyncio.to_thread(
|
||||
list_workstreams_with_history,
|
||||
limit=50,
|
||||
kind=cfg.list_kind,
|
||||
user_id=None,
|
||||
state=cfg.saved_state_filter,
|
||||
)
|
||||
visibility = WorkstreamProjectVisibility.for_request(request)
|
||||
|
||||
def _fetch_visible_rows() -> list[Any]:
|
||||
"""Page through storage until 50 visible rows (or exhaustion).
|
||||
|
||||
The visibility filter runs post-SQL, so a plain LIMIT-then-filter
|
||||
would silently shrink the window whenever recently-updated rows
|
||||
belong to private projects the caller can't see — their own rows
|
||||
at position 51+ would never surface. Paging with OFFSET restores
|
||||
the 'top-50 most-recent VISIBLE' contract. Runs entirely in the
|
||||
worker thread: both the query and the per-row project lookups
|
||||
are storage I/O. Bounded at 20 pages (1000 rows scanned) as a
|
||||
runaway guard; hitting it is logged, not silent.
|
||||
"""
|
||||
visible: list[Any] = []
|
||||
offset = 0
|
||||
page = 50
|
||||
max_pages = 20
|
||||
for _ in range(max_pages):
|
||||
batch = list_workstreams_with_history(
|
||||
limit=page,
|
||||
kind=cfg.list_kind,
|
||||
user_id=None,
|
||||
state=cfg.saved_state_filter,
|
||||
offset=offset,
|
||||
)
|
||||
for row in batch:
|
||||
# project_id / owner are the SELECT tail — see the column
|
||||
# order comment below.
|
||||
if visibility.ws_visible(row[15], ws_owner=row[16] or ""):
|
||||
visible.append(row)
|
||||
if len(visible) >= 50:
|
||||
return visible
|
||||
if len(batch) < page:
|
||||
return visible
|
||||
offset += page
|
||||
log.info(
|
||||
"ws.saved.visibility_scan_capped kind=%s scanned=%d visible=%d",
|
||||
cfg.list_kind,
|
||||
max_pages * page,
|
||||
len(visible),
|
||||
)
|
||||
return visible
|
||||
|
||||
rows = await asyncio.to_thread(_fetch_visible_rows)
|
||||
|
||||
# Coord-only: exclude ws_ids currently in the warm pool.
|
||||
loaded: set[str] = set()
|
||||
@@ -2710,12 +2771,13 @@ async def _collect_saved_rows(
|
||||
# Column order from list_workstreams_with_history (keep in sync with
|
||||
# the storage SELECT): ws_id, alias, title, name, created, updated,
|
||||
# message_count, node_id, state, kind, model_alias, launch_skill,
|
||||
# child_count, context_tokens, context_window. The occupancy ratio
|
||||
# is derived here (Python float division) rather than in SQL so the
|
||||
# NULL / zero-window cases stay obvious and identical across backends.
|
||||
# context_window is NULL for model aliases absent from
|
||||
# model_definitions (e.g. config.toml-only models), so context_ratio
|
||||
# degrades to 0.0 there rather than reporting a bogus occupancy.
|
||||
# child_count, context_tokens, context_window, project_id, owner.
|
||||
# The occupancy ratio is derived here (Python float division) rather
|
||||
# than in SQL so the NULL / zero-window cases stay obvious and
|
||||
# identical across backends. context_window is NULL for model
|
||||
# aliases absent from model_definitions (e.g. config.toml-only
|
||||
# models), so context_ratio degrades to 0.0 there rather than
|
||||
# reporting a bogus occupancy.
|
||||
result: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
(
|
||||
@@ -2734,6 +2796,8 @@ async def _collect_saved_rows(
|
||||
child_count,
|
||||
context_tokens,
|
||||
context_window,
|
||||
project_id,
|
||||
owner,
|
||||
) = row
|
||||
if wid in loaded:
|
||||
continue
|
||||
@@ -2758,6 +2822,7 @@ async def _collect_saved_rows(
|
||||
"child_count": child_count or 0,
|
||||
"context_tokens": ctx_tokens,
|
||||
"context_ratio": context_ratio,
|
||||
"project_id": project_id or None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -3240,6 +3305,29 @@ def make_history_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
messages = await asyncio.to_thread(
|
||||
project_history_messages, to_project, awaiting_approval
|
||||
)
|
||||
# Task-agent recall: attach each task_agent tool_call's stashed
|
||||
# sub-trajectory (projected step items) so the client's
|
||||
# ``replayHistory`` can rebuild the collapsible card. Live
|
||||
# in-memory session only — a cold/closed ws, or an entry evicted
|
||||
# past the LRU cap, has none, so the card renders the flat parent
|
||||
# record ("not retained"), never a fabricated 0-step card.
|
||||
# [[HYPOTHESIS]] an unobserved sub-trajectory is unknown, not none.
|
||||
get_traj = getattr(getattr(live_session, "ui", None), "get_agent_trajectory", None)
|
||||
if get_traj is not None:
|
||||
for msg in messages:
|
||||
for tc in msg.get("tool_calls") or ():
|
||||
# Only task_agent calls ever stash — skip the rest so
|
||||
# we don't take the agent-state lock once per tool_call
|
||||
# on a long history for ids that can never match.
|
||||
if tc.get("name") != "task_agent":
|
||||
continue
|
||||
steps = get_traj(tc.get("id") or "")
|
||||
# Attach only a well-formed, non-empty list — the
|
||||
# ``get_agent_trajectory`` contract is ``list | None``,
|
||||
# and the guard keeps a malformed result out of the
|
||||
# JSON payload (a non-list can't be serialized).
|
||||
if isinstance(steps, list) and steps:
|
||||
tc["agent_steps"] = steps
|
||||
except Exception:
|
||||
# Operationally interesting: a persistent decoration
|
||||
# failure (missing migration, driver mismatch, schema
|
||||
@@ -3597,7 +3685,7 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
|
||||
from turnstone.core import session_worker
|
||||
from turnstone.core.session import AttachmentsNotQueueableError, GenerationCancelled
|
||||
from turnstone.core.web_helpers import read_json_or_400
|
||||
from turnstone.core.web_helpers import auth_user_id, read_json_or_400
|
||||
|
||||
async def send(request: Request) -> Response:
|
||||
if cfg.permission_gate is not None:
|
||||
@@ -3630,6 +3718,14 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
if ui is None:
|
||||
return JSONResponse({"error": "session UI not available"}, status_code=409)
|
||||
|
||||
# The authenticated sender: threaded into the fresh-turn dispatch
|
||||
# below so per-user MCP credentials follow whoever is actually
|
||||
# driving a shared workstream. Deliberately NOT applied on the
|
||||
# live-worker queue path — an interjection folds into the current
|
||||
# turn under the initiator's identity (no mid-turn credential
|
||||
# switch); the next fresh turn rebinds.
|
||||
acting_uid = auth_user_id(request)
|
||||
|
||||
# ----- Attachment resolution (from the per-node upload buffer) -----
|
||||
send_id = ""
|
||||
requested_ids: list[str] = []
|
||||
@@ -3735,6 +3831,14 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
|
||||
kwargs["attachments"] = resolved_atts
|
||||
if send_id:
|
||||
kwargs["send_id"] = send_id
|
||||
# Fresh turn: rebind per-user MCP credentials to the
|
||||
# authenticated sender. Bound here (not via a send()
|
||||
# kwarg) so per-kind session stubs with explicit send
|
||||
# signatures keep working; getattr-guarded for the same
|
||||
# reason. The queue path above never rebinds.
|
||||
bind = getattr(session, "bind_acting_user", None)
|
||||
if acting_uid and callable(bind):
|
||||
bind(acting_uid)
|
||||
session.send(message, **kwargs)
|
||||
except GenerationCancelled:
|
||||
# Safety net — send() normally handles this internally.
|
||||
|
||||
@@ -25,6 +25,7 @@ from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import contextlib
|
||||
import contextvars
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
@@ -44,6 +45,25 @@ log = get_logger(__name__)
|
||||
# from bloating memory.
|
||||
_DEFAULT_LISTENER_QUEUE_MAX = 500
|
||||
|
||||
# Recall: how many finished task agents' projected sub-trajectories to retain
|
||||
# in memory for /history card rebuilds. LRU-bounded so a marathon workstream
|
||||
# can't grow it without limit; eviction (and a cold reopen, which starts empty)
|
||||
# recalls honestly as "not retained" rather than a fabricated 0-step card.
|
||||
_AGENT_TRAJECTORY_CAP = 256
|
||||
|
||||
# Sub-agent scope, PER-THREAD. A task agent's progress chatter reaches the UI as
|
||||
# ``on_info`` lines ("[task done] N chars", a tool's "fetched N chars") that
|
||||
# carry no call_id — they can't nest under the task card and would escape to the
|
||||
# top level, so the web pane drops them while a sub-agent runs. A contextvar,
|
||||
# not a session-global counter, so the suppression follows the sub-agent's OWN
|
||||
# thread: a parallel sibling tool running in another pool thread keeps its info
|
||||
# lines. Incremented/decremented by begin_/end_agent_scope around each
|
||||
# ``_run_agent``; the CLI overrides ``on_info`` and is unaffected (no card → the
|
||||
# lines are its only signal).
|
||||
_agent_scope_var: contextvars.ContextVar[int] = contextvars.ContextVar(
|
||||
"turnstone_agent_scope_depth", default=0
|
||||
)
|
||||
|
||||
|
||||
def _resolve_event_buffer_max() -> int:
|
||||
"""Read ``TURNSTONE_SSE_EVENT_BUFFER_MAX`` env override at import time.
|
||||
@@ -251,6 +271,31 @@ class SessionUIBase:
|
||||
# in :meth:`_enqueue`); the snapshot helper for the in-progress
|
||||
# replay path captures it under ``_listeners_lock`` too.
|
||||
self._event_id: int = 0
|
||||
# Sub-agent step tagging: child tool call_id -> parent task_agent
|
||||
# call_id. ``_enqueue`` reads it to stamp ``parent_call_id`` on every
|
||||
# child event (tool_pending / approve_request / tool_result /
|
||||
# tool_output_chunk / output_warning) so the UI can nest a task agent's
|
||||
# steps under its card. Written by ``note_agent_child`` /
|
||||
# ``clear_agent_children`` (the session brackets each ``_run_agent``);
|
||||
# keyed on the immutable call_id so it stays correct under the parent's
|
||||
# 4-wide parallel tool pool (several task agents in flight at once). Its
|
||||
# own lock so the hot fan-out path never serializes on ``_listeners_lock``.
|
||||
self._agent_children: dict[str, str] = {}
|
||||
self._agent_children_lock = threading.Lock()
|
||||
# Recall store: a finished task agent's projected sub-trajectory (step
|
||||
# items: id/name/arguments/output/is_error), keyed by its (parent)
|
||||
# call_id, so /history can rebuild the collapsible card after a fresh
|
||||
# connect / reopen while the workstream is still in memory. LRU-bounded
|
||||
# (oldest evicted past the cap); IN-MEMORY ONLY — durable persistence is
|
||||
# deferred, so a cold reopen (new session) finds it empty and renders the
|
||||
# flat parent record. Guarded by ``_agent_children_lock`` (same low-rate
|
||||
# agent-state path). [[HYPOTHESIS]] the sub-trajectory is the ledger;
|
||||
# an absent one is unknown ("not retained"), never none ("0 steps").
|
||||
self._agent_trajectories: collections.OrderedDict[str, list[dict[str, Any]]] = (
|
||||
collections.OrderedDict()
|
||||
)
|
||||
# (Sub-agent ``on_info`` suppression is per-thread via the module-level
|
||||
# ``_agent_scope_var`` contextvar — no instance field.)
|
||||
# Approval blocking — the worker thread calls approve_tools
|
||||
# which waits on _approval_event; the /approve endpoint sets
|
||||
# it via resolve_approval.
|
||||
@@ -445,6 +490,11 @@ class SessionUIBase:
|
||||
"""
|
||||
if "ws_id" not in data:
|
||||
data = {**data, "ws_id": self.ws_id}
|
||||
# Only events that can carry a child step — a top-level ``call_id`` or an
|
||||
# ``items`` list — need the parent-tag lookup; skip the lock+scan for the
|
||||
# high-frequency rest (content / reasoning / status / info / …).
|
||||
if self._agent_children and ("call_id" in data or "items" in data):
|
||||
data = self._stamp_agent_parent(data)
|
||||
with self._listeners_lock:
|
||||
self._event_id += 1
|
||||
event_id = self._event_id
|
||||
@@ -462,6 +512,107 @@ class SessionUIBase:
|
||||
lq.put_nowait(data)
|
||||
return event_id
|
||||
|
||||
def _stamp_agent_parent(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Stamp ``parent_call_id`` on a sub-agent's child event.
|
||||
|
||||
A task agent's sub-tool events flow through the same emit path as
|
||||
top-level tool events; the only thing marking them as a *child* is the
|
||||
``call_id`` registered in ``_agent_children`` by the session running
|
||||
``_run_agent``. Stamping at this one fan-out choke point (rather than at
|
||||
each call site) also catches events emitted from a tool's own background
|
||||
thread — a streaming bash's ``tool_output_chunk`` — which an ambient
|
||||
context var set on the worker thread would miss. Returns a shallow copy
|
||||
when it stamps; the input dict is never mutated."""
|
||||
with self._agent_children_lock:
|
||||
if not self._agent_children:
|
||||
return data
|
||||
cid = data.get("call_id")
|
||||
if isinstance(cid, str) and cid in self._agent_children:
|
||||
data = {**data, "parent_call_id": self._agent_children[cid]}
|
||||
items = data.get("items")
|
||||
if isinstance(items, list) and any(
|
||||
isinstance(it, dict) and it.get("call_id") in self._agent_children for it in items
|
||||
):
|
||||
data = {
|
||||
**data,
|
||||
"items": [
|
||||
{**it, "parent_call_id": self._agent_children[it["call_id"]]}
|
||||
if isinstance(it, dict) and it.get("call_id") in self._agent_children
|
||||
else it
|
||||
for it in items
|
||||
],
|
||||
}
|
||||
return data
|
||||
|
||||
def note_agent_child(self, child_call_id: str, parent_call_id: str) -> None:
|
||||
"""Register a sub-agent's child tool call so ``_enqueue`` tags its events
|
||||
with ``parent_call_id``. Called by the session for each sub-tool a
|
||||
``_run_agent`` issues, before the tool emits anything.
|
||||
|
||||
The session namespaces each sub-agent's child ids by parent
|
||||
(``f"{parent_call_id}::{tc_id}"``) before registering them here, so the
|
||||
key is unique even for local servers that assign per-response sequential
|
||||
ids (``call_0``) — two task agents in the parent's 4-wide pool can't
|
||||
collide and mis-nest steps."""
|
||||
if not child_call_id or not parent_call_id:
|
||||
return
|
||||
with self._agent_children_lock:
|
||||
self._agent_children[child_call_id] = parent_call_id
|
||||
|
||||
def clear_agent_children(self, parent_call_id: str) -> None:
|
||||
"""Drop every child registered under ``parent_call_id`` (the task agent
|
||||
finished). Bounds the registry to in-flight task agents. Deletes in
|
||||
place rather than reallocating the whole dict, so one agent completing
|
||||
doesn't churn other in-flight agents' entries."""
|
||||
with self._agent_children_lock:
|
||||
for c in [c for c, p in self._agent_children.items() if p == parent_call_id]:
|
||||
del self._agent_children[c]
|
||||
|
||||
def on_agent_step(self, parent_call_id: str, item: dict[str, Any]) -> None:
|
||||
"""Paint a sub-agent's auto-executed tool step as pending under its
|
||||
parent card so it shows live before it completes. (Approval-gated
|
||||
sub-tools paint via :meth:`approve_tools`.) The child is already
|
||||
registered via ``note_agent_child``, so ``_enqueue`` stamps
|
||||
``parent_call_id`` onto this ``tool_pending``."""
|
||||
self._enqueue({"type": "tool_pending", "items": self._serialize_approval_items([item])})
|
||||
|
||||
def begin_agent_scope(self) -> None:
|
||||
"""Enter a task agent's execution. Until the matching
|
||||
:meth:`end_agent_scope`, the web pane drops ``on_info`` lines from THIS
|
||||
thread (the task card carries the sub-agent's visible output, so the info
|
||||
chatter would only escape to the top level). Per-thread via
|
||||
:data:`_agent_scope_var` so a parallel SIBLING tool in another pool
|
||||
thread isn't suppressed; depth-counted for safety though task agents
|
||||
don't nest. The session brackets each ``_run_agent`` with this pair."""
|
||||
_agent_scope_var.set(_agent_scope_var.get() + 1)
|
||||
|
||||
def end_agent_scope(self) -> None:
|
||||
"""Leave a task agent's execution (see :meth:`begin_agent_scope`)."""
|
||||
_agent_scope_var.set(max(0, _agent_scope_var.get() - 1))
|
||||
|
||||
def stash_agent_trajectory(self, call_id: str, steps: list[dict[str, Any]]) -> None:
|
||||
"""Retain a finished task agent's projected sub-trajectory (step items)
|
||||
keyed by its call_id so ``/history`` can rebuild the card on a fresh
|
||||
connect / reopen while the workstream is in memory. LRU-bounded; the
|
||||
newest write is most-recently-used, oldest evicted past the cap. See
|
||||
:data:`_AGENT_TRAJECTORY_CAP` and the field comment in ``__init__``."""
|
||||
if not call_id:
|
||||
return
|
||||
with self._agent_children_lock:
|
||||
self._agent_trajectories[call_id] = steps
|
||||
self._agent_trajectories.move_to_end(call_id)
|
||||
while len(self._agent_trajectories) > _AGENT_TRAJECTORY_CAP:
|
||||
self._agent_trajectories.popitem(last=False)
|
||||
|
||||
def get_agent_trajectory(self, call_id: str) -> list[dict[str, Any]] | None:
|
||||
"""Read a stashed sub-trajectory, or ``None`` if not retained (evicted,
|
||||
or a cold reopen with an empty store). ``None`` is the honest "unknown"
|
||||
signal — the caller renders the flat parent record, never a 0-step card."""
|
||||
if not call_id:
|
||||
return None
|
||||
with self._agent_children_lock:
|
||||
return self._agent_trajectories.get(call_id)
|
||||
|
||||
def _register_listener(
|
||||
self, maxsize: int = _DEFAULT_LISTENER_QUEUE_MAX
|
||||
) -> queue.Queue[dict[str, Any]]:
|
||||
@@ -2400,6 +2551,14 @@ class SessionUIBase:
|
||||
log.warning("Failed to record usage event", exc_info=True)
|
||||
|
||||
def on_info(self, message: str) -> None:
|
||||
# Inside a task agent (on THIS thread), progress chatter ("[task done] N
|
||||
# chars", a tool's "fetched N chars") carries no call_id, so it can't
|
||||
# nest under the task card — drop it on the web pane rather than let it
|
||||
# escape to the top level. Per-thread, so a parallel sibling tool's info
|
||||
# still shows. The card shows the steps + result; the CLI overrides this
|
||||
# method and keeps the lines (no card there).
|
||||
if _agent_scope_var.get() > 0:
|
||||
return
|
||||
self._enqueue({"type": "info", "message": message})
|
||||
|
||||
def on_error(self, message: str) -> None:
|
||||
|
||||
@@ -31,6 +31,12 @@ class SettingDef:
|
||||
reference_url: str = "" # link to arXiv, docs, or provider reference
|
||||
|
||||
|
||||
# Default auto-compaction trigger as a fraction of the context window. Shared
|
||||
# with the ChatSession constructor (default + sub-0.1 coercion fallback) so the
|
||||
# "invalid → default" behavior cannot drift between the registry and the engine.
|
||||
DEFAULT_AUTO_COMPACT_PCT = 0.8
|
||||
|
||||
|
||||
def _build_registry() -> dict[str, SettingDef]:
|
||||
"""Build the settings registry from declarative definitions."""
|
||||
defs: list[SettingDef] = [
|
||||
@@ -138,10 +144,10 @@ def _build_registry() -> dict[str, SettingDef]:
|
||||
SettingDef(
|
||||
"session.auto_compact_pct",
|
||||
"float",
|
||||
0.8,
|
||||
"Auto-compact at this fraction of context window (0 = disabled)",
|
||||
DEFAULT_AUTO_COMPACT_PCT,
|
||||
"Auto-compact at this fraction of context window",
|
||||
"session",
|
||||
min_value=0.0,
|
||||
min_value=0.1,
|
||||
max_value=1.0,
|
||||
help="Automatically summarize older messages when the conversation fills this percentage "
|
||||
"of the context window. For example, 0.8 means compact when 80% full. This prevents "
|
||||
|
||||
@@ -74,9 +74,18 @@ from turnstone.core.storage._schema import (
|
||||
from turnstone.core.storage._schema import (
|
||||
prompt_policies as prompt_policies_t,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
COMPACTION_SOURCE as _COMPACTION_SOURCE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
HEURISTIC_RULE_MUTABLE as _HEURISTIC_RULE_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
HISTORY_CONTEXT_EXCLUSION_SQL as _HISTORY_EXCL_SQL,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
HISTORY_VISIBILITY_SCOPE_SQL as _HISTORY_SCOPE_SQL,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
LIKE_ESCAPE as _LIKE_ESCAPE,
|
||||
)
|
||||
@@ -107,9 +116,6 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
SKILL_MUTABLE as _SKILL_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
STRUCTURED_MEMORY_MUTABLE as _SMEM_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
VERDICT_MUTABLE as _VERDICT_MUTABLE,
|
||||
)
|
||||
@@ -121,6 +127,7 @@ from turnstone.core.storage._utils import (
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
find_orphan_conversations,
|
||||
parse_checkpoint_watermark,
|
||||
prepare_provider_data_for_save,
|
||||
purge_orphan_conversations,
|
||||
release_attachment_refs,
|
||||
@@ -136,7 +143,7 @@ from turnstone.core.storage._utils import (
|
||||
reconstruct_messages as _reconstruct_messages,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
reconstruct_turns as _reconstruct_turns,
|
||||
reconstruct_turns_checkpointed as _reconstruct_turns_checkpointed,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
recover_trajectory as _recover_trajectory,
|
||||
@@ -435,11 +442,19 @@ class PostgreSQLBackend:
|
||||
msg_rows, attachments = self._conversation_rows(ws_id, limit)
|
||||
return _reconstruct_messages(msg_rows, ws_id, attachments, repair=repair)
|
||||
|
||||
def load_message_turns(self, ws_id: str) -> list[Turn]:
|
||||
def load_message_turns(self, ws_id: str, *, checkpointed: bool = True) -> list[Turn]:
|
||||
"""Load the conversation as canonical ``Turn``s (unresolved AttachmentRef)
|
||||
for resume; bytes materialize at each output, never here."""
|
||||
for resume; bytes materialize at each output, never here.
|
||||
|
||||
Checkpoint-aware (``checkpointed=True``, resume default): a persisted
|
||||
compaction marker rehydrates only ``[summary] + [rows after its
|
||||
watermark]`` instead of the full pre-compaction transcript (which can
|
||||
overflow the window on reopen). ``checkpointed=False`` returns the full
|
||||
transcript (markers dropped) for export/audit consumers."""
|
||||
msg_rows, attachments = self._conversation_rows(ws_id, None)
|
||||
return _recover_trajectory(_reconstruct_turns(msg_rows, ws_id, attachments))
|
||||
return _recover_trajectory(
|
||||
_reconstruct_turns_checkpointed(msg_rows, ws_id, attachments, checkpoint=checkpointed)
|
||||
)
|
||||
|
||||
def _resolve_row_attachments(self, rows: Sequence[Any]) -> dict[int, list[dict[str, Any]]]:
|
||||
"""Build the ``reconstruct_messages`` attachment map from row ref-lists.
|
||||
@@ -471,6 +486,88 @@ class PostgreSQLBackend:
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None and row[0] is not None else None
|
||||
|
||||
def get_compaction_watermark(self, ws_id: str, preserve_tail: int = 0) -> int | None:
|
||||
"""Boundary id for a compaction checkpoint: the max conversation ``id``
|
||||
among the rows a compaction would summarize (see the sqlite twin).
|
||||
|
||||
The ``(N+1)``-th newest id counting REAL rows; compaction markers are
|
||||
excluded (summary artifacts, never part of the preserved tail — counting
|
||||
them would skew the boundary and drop real tail rows on resume). ``None``
|
||||
when empty.
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(conversations.c.id)
|
||||
.where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
sa.or_(
|
||||
conversations.c._source.is_(None),
|
||||
conversations.c._source != _COMPACTION_SOURCE,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(conversations.c.id.desc())
|
||||
.limit(1)
|
||||
.offset(max(0, preserve_tail))
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else None
|
||||
|
||||
def count_messages(self, ws_id: str) -> int:
|
||||
"""Total conversation rows for ``ws_id`` (compaction markers included)."""
|
||||
with self._conn() as conn:
|
||||
n = conn.execute(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(conversations)
|
||||
.where(conversations.c.ws_id == ws_id)
|
||||
).scalar()
|
||||
return int(n or 0)
|
||||
|
||||
def get_compaction_floor(self, ws_id: str) -> int:
|
||||
"""Rows backing the latest compaction summary that must survive rewind/
|
||||
retry: every row with ``id <= the latest marker's id`` (see the sqlite
|
||||
twin). ``0`` when the ws never compacted.
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
marker_id = conn.execute(
|
||||
sa.select(sa.func.max(conversations.c.id)).where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c._source == _COMPACTION_SOURCE,
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
if marker_id is None:
|
||||
return 0
|
||||
n = conn.execute(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(conversations)
|
||||
.where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c.id <= marker_id,
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
return int(n or 0)
|
||||
|
||||
def get_compaction_checkpoint(self, ws_id: str) -> int | None:
|
||||
"""Latest persisted marker's watermark — see the protocol docstring.
|
||||
``None`` = never compacted / malformed meta (whole ws is live)."""
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(conversations.c.meta)
|
||||
.where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c._source == _COMPACTION_SOURCE,
|
||||
)
|
||||
)
|
||||
.order_by(conversations.c.id.desc())
|
||||
.limit(1)
|
||||
).fetchone()
|
||||
return parse_checkpoint_watermark(row[0]) if row is not None else None
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
with self._conn() as conn:
|
||||
cutoff_row = conn.execute(
|
||||
@@ -520,9 +617,10 @@ class PostgreSQLBackend:
|
||||
kind: WorkstreamKind | str | None = None,
|
||||
user_id: str | None = None,
|
||||
state: str | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[Any]:
|
||||
# See SQLite sibling for the rationale on the kind / user_id / state filters.
|
||||
params: dict[str, Any] = {"limit": limit}
|
||||
params: dict[str, Any] = {"limit": limit, "offset": max(0, offset)}
|
||||
kind_clause = ""
|
||||
user_clause = ""
|
||||
state_clause = ""
|
||||
@@ -549,7 +647,7 @@ class PostgreSQLBackend:
|
||||
"(SELECT ue.prompt_tokens FROM usage_events ue "
|
||||
" WHERE ue.ws_id = w.ws_id "
|
||||
" ORDER BY ue.timestamp DESC LIMIT 1), "
|
||||
"md.context_window "
|
||||
"md.context_window, w.project_id, w.user_id "
|
||||
"FROM workstreams w "
|
||||
"LEFT JOIN workstream_config wcm "
|
||||
" ON wcm.ws_id = w.ws_id AND wcm.key = 'model_alias' "
|
||||
@@ -561,7 +659,7 @@ class PostgreSQLBackend:
|
||||
f"{kind_clause}"
|
||||
f"{user_clause}"
|
||||
f"{state_clause}"
|
||||
"ORDER BY w.updated DESC LIMIT :limit"
|
||||
"ORDER BY w.updated DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
params,
|
||||
).fetchall()
|
||||
@@ -1125,11 +1223,33 @@ class PostgreSQLBackend:
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
def search_history(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
exclude_ws_id: str | None = None,
|
||||
exclude_after: int | None = None,
|
||||
) -> list[Any]:
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
capped = min(int(limit), 100)
|
||||
capped_offset = max(0, int(offset))
|
||||
# Project-tenancy scope (see HISTORY_VISIBILITY_SCOPE_SQL) and the
|
||||
# live-context exclusion (HISTORY_CONTEXT_EXCLUSION_SQL): applied in
|
||||
# SQL, not post-filtered in Python, so limit/offset pagination stays
|
||||
# honest — a page never silently shrinks because hidden rows were
|
||||
# fetched then dropped.
|
||||
scope_sql = _HISTORY_SCOPE_SQL if user_id is not None else ""
|
||||
scope_params: dict[str, Any] = {"scope_user": user_id} if user_id is not None else {}
|
||||
if exclude_ws_id is not None:
|
||||
scope_sql += _HISTORY_EXCL_SQL
|
||||
# exclude_after=None → never compacted → the whole ws is live
|
||||
# context; ids start at 1, so -1 excludes every row.
|
||||
scope_params["excl_ws"] = exclude_ws_id
|
||||
scope_params["excl_after"] = -1 if exclude_after is None else exclude_after
|
||||
with self._conn() as conn:
|
||||
# Use PostgreSQL full-text search if search_vector column exists
|
||||
try:
|
||||
@@ -1140,11 +1260,22 @@ class PostgreSQLBackend:
|
||||
"FROM conversations c "
|
||||
"WHERE to_tsvector('english', COALESCE(c.content, '')) "
|
||||
" @@ plainto_tsquery('english', :query) "
|
||||
"ORDER BY ts_rank(to_tsvector('english', COALESCE(c.content, '')), "
|
||||
# Exclude compaction-checkpoint markers (resume-only
|
||||
# summary artifacts); IS DISTINCT FROM is NULL-safe so
|
||||
# normal rows (_source NULL) are not dropped.
|
||||
"AND c._source IS DISTINCT FROM :compaction_source "
|
||||
+ scope_sql
|
||||
+ "ORDER BY ts_rank(to_tsvector('english', COALESCE(c.content, '')), "
|
||||
" plainto_tsquery('english', :query)) DESC "
|
||||
"LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"query": query, "limit": capped, "offset": capped_offset},
|
||||
{
|
||||
"query": query,
|
||||
"compaction_source": _COMPACTION_SOURCE,
|
||||
"limit": capped,
|
||||
"offset": capped_offset,
|
||||
**scope_params,
|
||||
},
|
||||
).fetchall()
|
||||
)
|
||||
except Exception:
|
||||
@@ -1152,24 +1283,37 @@ class PostgreSQLBackend:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations WHERE content ILIKE :pattern "
|
||||
"ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
|
||||
"FROM conversations c WHERE c.content ILIKE :pattern "
|
||||
"AND c._source IS DISTINCT FROM :compaction_source "
|
||||
+ scope_sql
|
||||
+ "ORDER BY c.timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"pattern": f"%{query}%", "limit": capped, "offset": capped_offset},
|
||||
{
|
||||
"pattern": f"%{query}%",
|
||||
"compaction_source": _COMPACTION_SOURCE,
|
||||
"limit": capped,
|
||||
"offset": capped_offset,
|
||||
**scope_params,
|
||||
},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
def search_history_recent(self, limit: int = 20) -> list[Any]:
|
||||
def search_history_recent(self, limit: int = 20, *, user_id: str | None = None) -> list[Any]:
|
||||
capped = min(limit, 100)
|
||||
scope_sql = _HISTORY_SCOPE_SQL if user_id is not None else ""
|
||||
scope_params = {"scope_user": user_id} if user_id is not None else {}
|
||||
with self._conn() as conn:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations ORDER BY timestamp DESC LIMIT :limit"
|
||||
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
|
||||
"FROM conversations c "
|
||||
"WHERE c._source IS DISTINCT FROM :compaction_source "
|
||||
+ scope_sql
|
||||
+ "ORDER BY c.timestamp DESC LIMIT :limit"
|
||||
),
|
||||
{"limit": capped},
|
||||
{"limit": capped, "compaction_source": _COMPACTION_SOURCE, **scope_params},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
@@ -3903,6 +4047,56 @@ class PostgreSQLBackend:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def upsert_structured_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
mem_type: str | None,
|
||||
scope: str,
|
||||
scope_id: str,
|
||||
content: str,
|
||||
) -> tuple[dict[str, str], bool]:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
insert_stmt = pg_insert(structured_memories).values(
|
||||
memory_id=memory_id,
|
||||
name=name,
|
||||
description="" if description is None else description,
|
||||
type="general" if mem_type is None else mem_type,
|
||||
scope=scope,
|
||||
scope_id=scope_id,
|
||||
content=content,
|
||||
created=now,
|
||||
updated=now,
|
||||
last_accessed=now,
|
||||
access_count=0,
|
||||
)
|
||||
# On conflict, refresh content + timestamps. description/type are
|
||||
# overwritten only when the caller supplied them; None means "unset" ->
|
||||
# keep the stored value. created and access_count are left untouched.
|
||||
set_: dict[str, Any] = {
|
||||
"content": insert_stmt.excluded.content,
|
||||
"updated": now,
|
||||
"last_accessed": now,
|
||||
}
|
||||
if description is not None:
|
||||
set_["description"] = insert_stmt.excluded.description
|
||||
if mem_type is not None:
|
||||
set_["type"] = insert_stmt.excluded.type
|
||||
stmt = insert_stmt.on_conflict_do_update(
|
||||
index_elements=["name", "scope", "scope_id"],
|
||||
set_=set_,
|
||||
).returning(structured_memories)
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(stmt).fetchone()
|
||||
conn.commit()
|
||||
if row is None: # unreachable: ON CONFLICT DO UPDATE returns one row
|
||||
return {}, False
|
||||
result = dict(row._mapping)
|
||||
return result, result["memory_id"] != memory_id
|
||||
|
||||
def get_structured_memory(self, memory_id: str) -> dict[str, str] | None:
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -3925,22 +4119,6 @@ class PostgreSQLBackend:
|
||||
).fetchone()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
def update_structured_memory(self, memory_id: str, **fields: str) -> bool:
|
||||
fields = {k: v for k, v in fields.items() if k in _SMEM_MUTABLE}
|
||||
if not fields:
|
||||
return False
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
fields["updated"] = now
|
||||
fields["last_accessed"] = now
|
||||
with self._conn() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(structured_memories)
|
||||
.where(structured_memories.c.memory_id == memory_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_structured_memory(
|
||||
self, name: str, scope: str = "global", scope_id: str = ""
|
||||
) -> bool:
|
||||
@@ -5033,6 +5211,101 @@ class PostgreSQLBackend:
|
||||
).scalar()
|
||||
return result is not None
|
||||
|
||||
def list_workstreams_for_project(self, project_id: str) -> list[dict[str, Any]]:
|
||||
# See SQLite sibling for the projection rationale.
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
workstreams.c.ws_id,
|
||||
workstreams.c.name,
|
||||
workstreams.c.title,
|
||||
workstreams.c.state,
|
||||
workstreams.c.kind,
|
||||
workstreams.c.updated,
|
||||
workstreams.c.node_id,
|
||||
workstreams.c.user_id,
|
||||
)
|
||||
.where(workstreams.c.project_id == project_id)
|
||||
.order_by(workstreams.c.updated.desc())
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"ws_id": r[0],
|
||||
"name": r[1],
|
||||
"title": r[2],
|
||||
"state": r[3],
|
||||
"kind": r[4],
|
||||
"updated": r[5],
|
||||
"node_id": r[6],
|
||||
"user_id": r[7],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def list_project_attachments(self, project_id: str) -> list[dict[str, Any]]:
|
||||
# See SQLite sibling: metadata-only (never the content blob), each
|
||||
# id paired with its first referencing ws_id for URL construction.
|
||||
with self._conn() as conn:
|
||||
ref_rows = conn.execute(
|
||||
sa.select(conversations.c.ws_id, conversations.c.attachments)
|
||||
.select_from(
|
||||
conversations.join(workstreams, workstreams.c.ws_id == conversations.c.ws_id)
|
||||
)
|
||||
.where(
|
||||
workstreams.c.project_id == project_id,
|
||||
conversations.c.attachments.is_not(None),
|
||||
)
|
||||
.order_by(conversations.c.id)
|
||||
).fetchall()
|
||||
first_ws: dict[str, str] = {}
|
||||
for ws_id, raw in ref_rows:
|
||||
try:
|
||||
ids = json.loads(raw) if raw else []
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not isinstance(ids, list):
|
||||
continue
|
||||
for aid in ids:
|
||||
if isinstance(aid, str) and aid and aid not in first_ws:
|
||||
first_ws[aid] = ws_id
|
||||
if not first_ws:
|
||||
return []
|
||||
# Chunk the IN() — a project can reference more distinct
|
||||
# blobs than the driver's bind-parameter cap.
|
||||
meta: dict[str, Any] = {}
|
||||
ids = list(first_ws)
|
||||
for i in range(0, len(ids), 500):
|
||||
meta_rows = conn.execute(
|
||||
sa.select(
|
||||
workstream_attachments.c.attachment_id,
|
||||
workstream_attachments.c.filename,
|
||||
workstream_attachments.c.mime_type,
|
||||
workstream_attachments.c.size_bytes,
|
||||
workstream_attachments.c.kind,
|
||||
workstream_attachments.c.created,
|
||||
).where(workstream_attachments.c.attachment_id.in_(ids[i : i + 500]))
|
||||
).fetchall()
|
||||
for r in meta_rows:
|
||||
meta[r[0]] = r
|
||||
out: list[dict[str, Any]] = []
|
||||
for aid, ws_id in first_ws.items():
|
||||
m = meta.get(aid)
|
||||
if m is None:
|
||||
# Ref-list names a pruned blob (refcount GC) — skip.
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"attachment_id": m[0],
|
||||
"filename": m[1],
|
||||
"mime_type": m[2],
|
||||
"size_bytes": m[3],
|
||||
"kind": m[4],
|
||||
"created": m[5],
|
||||
"ws_id": ws_id,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
# -- OIDC identity ---------------------------------------------------------
|
||||
|
||||
def create_oidc_user(
|
||||
|
||||
@@ -225,14 +225,19 @@ class StorageBackend(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def load_message_turns(self, ws_id: str) -> list[Turn]:
|
||||
"""Load a workstream's full history as canonical ``Turn``s for resume.
|
||||
def load_message_turns(self, ws_id: str, *, checkpointed: bool = True) -> list[Turn]:
|
||||
"""Load a workstream's history as canonical ``Turn``s for resume.
|
||||
|
||||
Unlike :meth:`load_messages` this keeps attachments *by reference*
|
||||
(:class:`AttachmentRef`) — ``session.messages`` is the canonical Turn
|
||||
trajectory and materializes bytes only at each output (wire / display).
|
||||
The trailing-incomplete-tool-call strip (``recover_trajectory``) is
|
||||
applied; mid-conversation orphans are left for the send-time repair.
|
||||
|
||||
``checkpointed=True`` (resume default) honors a persisted compaction
|
||||
marker and returns the bounded ``[summary] + [tail]`` view;
|
||||
``checkpointed=False`` returns the full transcript (markers dropped) for
|
||||
export/audit consumers that must not lose pre-compaction history.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -249,6 +254,43 @@ class StorageBackend(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_compaction_watermark(self, ws_id: str, preserve_tail: int = 0) -> int | None:
|
||||
"""Boundary id for a compaction checkpoint marker.
|
||||
|
||||
The max conversation ``id`` among the rows a compaction would
|
||||
summarize: ``max(id)`` when ``preserve_tail=0`` (the auto/overflow
|
||||
path), or the ``(N+1)``-th newest id when ``preserve_tail=N`` keeps
|
||||
the newest ``N`` rows verbatim. Persisted in the marker's ``meta`` so
|
||||
resume can rehydrate ``[summary] + [rows after the watermark]``.
|
||||
``None`` when the workstream has no rows.
|
||||
"""
|
||||
...
|
||||
|
||||
def count_messages(self, ws_id: str) -> int:
|
||||
"""Total conversation rows for ``ws_id`` (compaction markers included)."""
|
||||
...
|
||||
|
||||
def get_compaction_floor(self, ws_id: str) -> int:
|
||||
"""Rows backing the latest compaction summary that rewind/retry must not
|
||||
delete: every row with ``id <= the latest marker's id`` (summarized
|
||||
prefix + marker). ``0`` when the workstream never compacted. Used to
|
||||
floor the rewind/retry truncation so the summary's backing survives.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_compaction_checkpoint(self, ws_id: str) -> int | None:
|
||||
"""The latest persisted compaction marker's watermark for ``ws_id``.
|
||||
|
||||
Every row with ``id <=`` the returned boundary was folded into the
|
||||
summary the live session now holds — the summarized-away past; rows
|
||||
above it are the live segment still in the model's context. Distinct
|
||||
from :meth:`get_compaction_watermark`, which computes the boundary a
|
||||
NEW compaction would use; this reads the one already persisted.
|
||||
``None`` when the workstream never compacted or the marker's meta is
|
||||
malformed (callers must then treat the WHOLE workstream as live).
|
||||
"""
|
||||
...
|
||||
|
||||
# -- Workstream attachments (content-addressed, refcounted) ---------------
|
||||
#
|
||||
# Pending (uploaded-but-unsent) bytes live in the per-node in-memory
|
||||
@@ -333,9 +375,14 @@ class StorageBackend(Protocol):
|
||||
kind: WorkstreamKind | str | None = None,
|
||||
user_id: str | None = None,
|
||||
state: str | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[Any]:
|
||||
"""List workstreams that have messages, ordered by updated DESC.
|
||||
|
||||
``offset`` skips that many rows before applying ``limit`` — the
|
||||
saved-list collector pages through with it so a post-SQL
|
||||
visibility filter can keep fetching until it fills its window.
|
||||
|
||||
``kind`` filters at the SQL layer — pass ``WorkstreamKind.INTERACTIVE``
|
||||
from the interactive "saved workstreams" sidebar so coordinator rows
|
||||
(which also persist conversation history) don't leak into that
|
||||
@@ -445,6 +492,34 @@ class StorageBackend(Protocol):
|
||||
"""Create a structured memory record."""
|
||||
...
|
||||
|
||||
def upsert_structured_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
mem_type: str | None,
|
||||
scope: str,
|
||||
scope_id: str,
|
||||
content: str,
|
||||
) -> tuple[dict[str, str], bool]:
|
||||
"""Insert a structured memory, or update it in place on a
|
||||
``(name, scope, scope_id)`` conflict.
|
||||
|
||||
Atomic ``INSERT ... ON CONFLICT DO UPDATE ... RETURNING`` — no
|
||||
IntegrityError round-trip, race-safe under concurrent saves of the same
|
||||
key. ``description`` / ``mem_type`` of ``None`` mean "unset": the
|
||||
column default ("" / "general") is used on insert and the stored value
|
||||
is kept on conflict; a non-``None`` value (including "" or "general") is
|
||||
written.
|
||||
|
||||
Returns ``(row, was_update)`` (like Django's ``update_or_create``): the
|
||||
full saved row, and ``True`` when an existing row was updated rather
|
||||
than inserted. Callers MUST supply a fresh unique ``memory_id`` — it is
|
||||
compared against the returned row's id to tell INSERT from UPDATE, so a
|
||||
reused id would report ``was_update=False`` on a real update.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_structured_memory(self, memory_id: str) -> dict[str, str] | None:
|
||||
"""Return structured memory dict or None."""
|
||||
...
|
||||
@@ -455,10 +530,6 @@ class StorageBackend(Protocol):
|
||||
"""Lookup structured memory by (name, scope, scope_id). Returns dict or None."""
|
||||
...
|
||||
|
||||
def update_structured_memory(self, memory_id: str, **fields: str) -> bool:
|
||||
"""Update specified fields on a structured memory. Returns True if found."""
|
||||
...
|
||||
|
||||
def delete_structured_memory(
|
||||
self, name: str, scope: str = "global", scope_id: str = ""
|
||||
) -> bool:
|
||||
@@ -719,12 +790,44 @@ class StorageBackend(Protocol):
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
"""Search conversation history. Returns (timestamp, ws_id, role, content, tool_name)."""
|
||||
def search_history(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
exclude_ws_id: str | None = None,
|
||||
exclude_after: int | None = None,
|
||||
) -> list[Any]:
|
||||
"""Search conversation history. Returns (timestamp, ws_id, role, content, tool_name).
|
||||
|
||||
``user_id`` scopes results by project tenancy: rows are dropped when
|
||||
their workstream sits in an existing PRIVATE project and *user_id* is
|
||||
neither the workstream creator, the project owner, nor a member.
|
||||
Everything else — no project link, dangling link, non-private project
|
||||
— stays visible (trusted-team default). The SQL predicate mirrors
|
||||
``WorkstreamProjectVisibility`` in ``core.auth`` (THE statement of the
|
||||
rule); ``tests/test_search_history_visibility.py`` pins the parity.
|
||||
``None`` (default) applies no scoping — correct only for single-user
|
||||
lanes (local CLI); authenticated surfaces MUST pass the acting user.
|
||||
|
||||
``exclude_ws_id`` + ``exclude_after`` drop *exclude_ws_id*'s rows with
|
||||
``id > exclude_after`` — the live-context exclusion for the
|
||||
model-facing recall tool (rows the model can already see; see
|
||||
``HISTORY_CONTEXT_EXCLUSION_SQL``). ``exclude_after=None`` with an
|
||||
``exclude_ws_id`` set excludes the entire workstream (never
|
||||
compacted → all live). Both applied in SQL so pagination stays
|
||||
honest.
|
||||
"""
|
||||
...
|
||||
|
||||
def search_history_recent(self, limit: int = 20) -> list[Any]:
|
||||
"""Return most recent conversation messages."""
|
||||
def search_history_recent(self, limit: int = 20, *, user_id: str | None = None) -> list[Any]:
|
||||
"""Return most recent conversation messages.
|
||||
|
||||
``user_id`` scopes rows by project tenancy exactly as in
|
||||
:meth:`search_history`; ``None`` applies no scoping.
|
||||
"""
|
||||
...
|
||||
|
||||
# -- User identity operations -----------------------------------------------
|
||||
@@ -2266,6 +2369,17 @@ class StorageBackend(Protocol):
|
||||
"""Return True if user_id is a member of project_id."""
|
||||
...
|
||||
|
||||
def list_workstreams_for_project(self, project_id: str) -> list[dict[str, Any]]:
|
||||
"""Return the project's workstreams (ws_id, name, title, state, kind,
|
||||
updated, node_id, user_id), newest-updated first."""
|
||||
...
|
||||
|
||||
def list_project_attachments(self, project_id: str) -> list[dict[str, Any]]:
|
||||
"""Committed attachments referenced by any turn in the project's
|
||||
workstreams — metadata only, each with the first referencing ws_id
|
||||
(content serving is ws-scoped)."""
|
||||
...
|
||||
|
||||
# -- Prompt policies -------------------------------------------------------
|
||||
|
||||
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
|
||||
@@ -74,9 +74,18 @@ from turnstone.core.storage._schema import (
|
||||
from turnstone.core.storage._schema import (
|
||||
prompt_policies as prompt_policies_t,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
COMPACTION_SOURCE as _COMPACTION_SOURCE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
HEURISTIC_RULE_MUTABLE as _HEURISTIC_RULE_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
HISTORY_CONTEXT_EXCLUSION_SQL as _HISTORY_EXCL_SQL,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
HISTORY_VISIBILITY_SCOPE_SQL as _HISTORY_SCOPE_SQL,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
LIKE_ESCAPE as _LIKE_ESCAPE,
|
||||
)
|
||||
@@ -107,9 +116,6 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
SKILL_MUTABLE as _SKILL_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
STRUCTURED_MEMORY_MUTABLE as _SMEM_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
VERDICT_MUTABLE as _VERDICT_MUTABLE,
|
||||
)
|
||||
@@ -121,6 +127,7 @@ from turnstone.core.storage._utils import (
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
find_orphan_conversations,
|
||||
parse_checkpoint_watermark,
|
||||
prepare_provider_data_for_save,
|
||||
purge_orphan_conversations,
|
||||
release_attachment_refs,
|
||||
@@ -136,7 +143,7 @@ from turnstone.core.storage._utils import (
|
||||
reconstruct_messages as _reconstruct_messages,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
reconstruct_turns as _reconstruct_turns,
|
||||
reconstruct_turns_checkpointed as _reconstruct_turns_checkpointed,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
recover_trajectory as _recover_trajectory,
|
||||
@@ -500,14 +507,24 @@ class SQLiteBackend:
|
||||
msg_rows, attachments = self._conversation_rows(ws_id, limit)
|
||||
return _reconstruct_messages(msg_rows, ws_id, attachments, repair=repair)
|
||||
|
||||
def load_message_turns(self, ws_id: str) -> list[Turn]:
|
||||
def load_message_turns(self, ws_id: str, *, checkpointed: bool = True) -> list[Turn]:
|
||||
"""Load the conversation as canonical ``Turn``s (unresolved AttachmentRef).
|
||||
|
||||
The resume path: ``session.messages`` holds the by-reference content;
|
||||
bytes are materialized at each output (wire / display), never here.
|
||||
|
||||
Checkpoint-aware (``checkpointed=True``, the resume default): if the
|
||||
conversation carries a persisted compaction marker, only ``[summary] +
|
||||
[rows after its watermark]`` rehydrate (the bounded view the live session
|
||||
held when it compacted) — not the full pre-compaction transcript, which
|
||||
can overflow the model window on reopen. ``checkpointed=False`` returns
|
||||
the full transcript (markers dropped) for export/audit consumers that
|
||||
must not lose pre-compaction history.
|
||||
"""
|
||||
msg_rows, attachments = self._conversation_rows(ws_id, None)
|
||||
return _recover_trajectory(_reconstruct_turns(msg_rows, ws_id, attachments))
|
||||
return _recover_trajectory(
|
||||
_reconstruct_turns_checkpointed(msg_rows, ws_id, attachments, checkpoint=checkpointed)
|
||||
)
|
||||
|
||||
def _resolve_row_attachments(self, rows: Sequence[Any]) -> dict[int, list[dict[str, Any]]]:
|
||||
"""Build the ``reconstruct_messages`` attachment map from row ref-lists.
|
||||
@@ -539,6 +556,97 @@ class SQLiteBackend:
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None and row[0] is not None else None
|
||||
|
||||
def get_compaction_watermark(self, ws_id: str, preserve_tail: int = 0) -> int | None:
|
||||
"""Boundary id for a compaction checkpoint: the max conversation ``id``
|
||||
among the rows a compaction would summarize.
|
||||
|
||||
With ``preserve_tail=0`` (the auto/overflow path) every current row is
|
||||
summarized, so this is the newest real id. With ``preserve_tail=N`` the
|
||||
newest ``N`` rows are kept verbatim, so the boundary is the ``(N+1)``-th
|
||||
newest id — counting REAL rows from the newest. Compaction markers are
|
||||
excluded: they are summary artifacts written as new rows but never part
|
||||
of the preserved in-memory tail, so counting them would skew the boundary
|
||||
and drop real tail rows on resume. ``None`` when there are no rows.
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(conversations.c.id)
|
||||
.where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
sa.or_(
|
||||
conversations.c._source.is_(None),
|
||||
conversations.c._source != _COMPACTION_SOURCE,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(conversations.c.id.desc())
|
||||
.limit(1)
|
||||
.offset(max(0, preserve_tail))
|
||||
).fetchone()
|
||||
return int(row[0]) if row is not None else None
|
||||
|
||||
def count_messages(self, ws_id: str) -> int:
|
||||
"""Total conversation rows for ``ws_id`` (compaction markers included)."""
|
||||
with self._conn() as conn:
|
||||
n = conn.execute(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(conversations)
|
||||
.where(conversations.c.ws_id == ws_id)
|
||||
).scalar()
|
||||
return int(n or 0)
|
||||
|
||||
def get_compaction_floor(self, ws_id: str) -> int:
|
||||
"""Rows that back the latest compaction summary and must survive a
|
||||
rewind/retry: every row with ``id <= the latest marker's id`` (the
|
||||
summarized prefix plus the marker). ``0`` when the ws never compacted.
|
||||
|
||||
rewind/retry trim the conversation TAIL, but after a compaction the
|
||||
in-memory summary turns no longer map 1:1 to storage rows, so a delete
|
||||
keyed on ``len(self.messages)`` would keep the oldest summarized rows and
|
||||
drop the marker. Flooring the delete at this count keeps the summary's
|
||||
backing intact.
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
marker_id = conn.execute(
|
||||
sa.select(sa.func.max(conversations.c.id)).where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c._source == _COMPACTION_SOURCE,
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
if marker_id is None:
|
||||
return 0
|
||||
n = conn.execute(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(conversations)
|
||||
.where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c.id <= marker_id,
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
return int(n or 0)
|
||||
|
||||
def get_compaction_checkpoint(self, ws_id: str) -> int | None:
|
||||
"""Latest persisted marker's watermark — see the protocol docstring.
|
||||
``None`` = never compacted / malformed meta (whole ws is live)."""
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(conversations.c.meta)
|
||||
.where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c._source == _COMPACTION_SOURCE,
|
||||
)
|
||||
)
|
||||
.order_by(conversations.c.id.desc())
|
||||
.limit(1)
|
||||
).fetchone()
|
||||
return parse_checkpoint_watermark(row[0]) if row is not None else None
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
with self._conn() as conn:
|
||||
# Find the id of the first row to delete (the row at offset keep_count)
|
||||
@@ -602,6 +710,7 @@ class SQLiteBackend:
|
||||
kind: WorkstreamKind | str | None = None,
|
||||
user_id: str | None = None,
|
||||
state: str | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[Any]:
|
||||
# ``kind`` filter applied at the SQL layer so coordinator rows
|
||||
# (which persist conversation history the same way interactive
|
||||
@@ -615,7 +724,7 @@ class SQLiteBackend:
|
||||
# ``state`` filter — coordinator-saved surface passes "closed"
|
||||
# so deleted / currently-active rows don't end up in the saved
|
||||
# cards (which would 404 on click or duplicate the active list).
|
||||
params: dict[str, Any] = {"limit": limit}
|
||||
params: dict[str, Any] = {"limit": limit, "offset": max(0, offset)}
|
||||
kind_clause = ""
|
||||
user_clause = ""
|
||||
state_clause = ""
|
||||
@@ -642,7 +751,7 @@ class SQLiteBackend:
|
||||
"(SELECT ue.prompt_tokens FROM usage_events ue "
|
||||
" WHERE ue.ws_id = w.ws_id "
|
||||
" ORDER BY ue.timestamp DESC LIMIT 1), "
|
||||
"md.context_window "
|
||||
"md.context_window, w.project_id, w.user_id "
|
||||
"FROM workstreams w "
|
||||
"LEFT JOIN workstream_config wcm "
|
||||
" ON wcm.ws_id = w.ws_id AND wcm.key = 'model_alias' "
|
||||
@@ -654,7 +763,7 @@ class SQLiteBackend:
|
||||
f"{kind_clause}"
|
||||
f"{user_clause}"
|
||||
f"{state_clause}"
|
||||
"ORDER BY w.updated DESC LIMIT :limit"
|
||||
"ORDER BY w.updated DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
params,
|
||||
).fetchall()
|
||||
@@ -1289,11 +1398,33 @@ class SQLiteBackend:
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
def search_history(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
exclude_ws_id: str | None = None,
|
||||
exclude_after: int | None = None,
|
||||
) -> list[Any]:
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
capped = min(int(limit), 100)
|
||||
capped_offset = max(0, int(offset))
|
||||
# Project-tenancy scope (see HISTORY_VISIBILITY_SCOPE_SQL) and the
|
||||
# live-context exclusion (HISTORY_CONTEXT_EXCLUSION_SQL): applied in
|
||||
# SQL, not post-filtered in Python, so limit/offset pagination stays
|
||||
# honest — a page never silently shrinks because hidden rows were
|
||||
# fetched then dropped.
|
||||
scope_sql = _HISTORY_SCOPE_SQL if user_id is not None else ""
|
||||
scope_params: dict[str, Any] = {"scope_user": user_id} if user_id is not None else {}
|
||||
if exclude_ws_id is not None:
|
||||
scope_sql += _HISTORY_EXCL_SQL
|
||||
# exclude_after=None → never compacted → the whole ws is live
|
||||
# context; ids start at 1, so -1 excludes every row.
|
||||
scope_params["excl_ws"] = exclude_ws_id
|
||||
scope_params["excl_after"] = -1 if exclude_after is None else exclude_after
|
||||
with self._conn() as conn:
|
||||
if self._fts5_available:
|
||||
return list(
|
||||
@@ -1303,36 +1434,56 @@ class SQLiteBackend:
|
||||
"FROM conversations_fts f "
|
||||
"JOIN conversations c ON c.id = f.rowid "
|
||||
"WHERE conversations_fts MATCH :query "
|
||||
"ORDER BY f.rank ASC LIMIT :limit OFFSET :offset"
|
||||
# Exclude compaction-checkpoint markers (resume-only
|
||||
# summary artifacts); normal rows store _source NULL,
|
||||
# so the filter must be NULL-safe or it drops everything.
|
||||
"AND (c._source IS NULL OR c._source <> :compaction_source) "
|
||||
+ scope_sql
|
||||
+ "ORDER BY f.rank ASC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"query": _fts5_query(query), "limit": capped, "offset": capped_offset},
|
||||
{
|
||||
"query": _fts5_query(query),
|
||||
"compaction_source": _COMPACTION_SOURCE,
|
||||
"limit": capped,
|
||||
"offset": capped_offset,
|
||||
**scope_params,
|
||||
},
|
||||
).fetchall()
|
||||
)
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations WHERE content LIKE :pattern ESCAPE '\\' "
|
||||
"ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
|
||||
"FROM conversations c WHERE c.content LIKE :pattern ESCAPE '\\' "
|
||||
"AND (c._source IS NULL OR c._source <> :compaction_source) "
|
||||
+ scope_sql
|
||||
+ "ORDER BY c.timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{
|
||||
"pattern": f"%{_escape_like(query)}%",
|
||||
"compaction_source": _COMPACTION_SOURCE,
|
||||
"limit": capped,
|
||||
"offset": capped_offset,
|
||||
**scope_params,
|
||||
},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
def search_history_recent(self, limit: int = 20) -> list[Any]:
|
||||
def search_history_recent(self, limit: int = 20, *, user_id: str | None = None) -> list[Any]:
|
||||
capped = min(limit, 100)
|
||||
scope_sql = _HISTORY_SCOPE_SQL if user_id is not None else ""
|
||||
scope_params = {"scope_user": user_id} if user_id is not None else {}
|
||||
with self._conn() as conn:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations ORDER BY timestamp DESC LIMIT :limit"
|
||||
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
|
||||
"FROM conversations c "
|
||||
"WHERE (c._source IS NULL OR c._source <> :compaction_source) "
|
||||
+ scope_sql
|
||||
+ "ORDER BY c.timestamp DESC LIMIT :limit"
|
||||
),
|
||||
{"limit": capped},
|
||||
{"limit": capped, "compaction_source": _COMPACTION_SOURCE, **scope_params},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
@@ -4078,6 +4229,56 @@ class SQLiteBackend:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def upsert_structured_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
mem_type: str | None,
|
||||
scope: str,
|
||||
scope_id: str,
|
||||
content: str,
|
||||
) -> tuple[dict[str, str], bool]:
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
insert_stmt = sqlite_insert(structured_memories).values(
|
||||
memory_id=memory_id,
|
||||
name=name,
|
||||
description="" if description is None else description,
|
||||
type="general" if mem_type is None else mem_type,
|
||||
scope=scope,
|
||||
scope_id=scope_id,
|
||||
content=content,
|
||||
created=now,
|
||||
updated=now,
|
||||
last_accessed=now,
|
||||
access_count=0,
|
||||
)
|
||||
# On conflict, refresh content + timestamps. description/type are
|
||||
# overwritten only when the caller supplied them; None means "unset" ->
|
||||
# keep the stored value. created and access_count are left untouched.
|
||||
set_: dict[str, Any] = {
|
||||
"content": insert_stmt.excluded.content,
|
||||
"updated": now,
|
||||
"last_accessed": now,
|
||||
}
|
||||
if description is not None:
|
||||
set_["description"] = insert_stmt.excluded.description
|
||||
if mem_type is not None:
|
||||
set_["type"] = insert_stmt.excluded.type
|
||||
stmt = insert_stmt.on_conflict_do_update(
|
||||
index_elements=["name", "scope", "scope_id"],
|
||||
set_=set_,
|
||||
).returning(structured_memories)
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(stmt).fetchone()
|
||||
conn.commit()
|
||||
if row is None: # unreachable: ON CONFLICT DO UPDATE returns one row
|
||||
return {}, False
|
||||
result = dict(row._mapping)
|
||||
return result, result["memory_id"] != memory_id
|
||||
|
||||
def get_structured_memory(self, memory_id: str) -> dict[str, str] | None:
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -4100,22 +4301,6 @@ class SQLiteBackend:
|
||||
).fetchone()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
def update_structured_memory(self, memory_id: str, **fields: str) -> bool:
|
||||
fields = {k: v for k, v in fields.items() if k in _SMEM_MUTABLE}
|
||||
if not fields:
|
||||
return False
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
fields["updated"] = now
|
||||
fields["last_accessed"] = now
|
||||
with self._conn() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(structured_memories)
|
||||
.where(structured_memories.c.memory_id == memory_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_structured_memory(
|
||||
self, name: str, scope: str = "global", scope_id: str = ""
|
||||
) -> bool:
|
||||
@@ -5189,6 +5374,103 @@ class SQLiteBackend:
|
||||
).scalar()
|
||||
return result is not None
|
||||
|
||||
def list_workstreams_for_project(self, project_id: str) -> list[dict[str, Any]]:
|
||||
with self._conn() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
workstreams.c.ws_id,
|
||||
workstreams.c.name,
|
||||
workstreams.c.title,
|
||||
workstreams.c.state,
|
||||
workstreams.c.kind,
|
||||
workstreams.c.updated,
|
||||
workstreams.c.node_id,
|
||||
workstreams.c.user_id,
|
||||
)
|
||||
.where(workstreams.c.project_id == project_id)
|
||||
.order_by(workstreams.c.updated.desc())
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"ws_id": r[0],
|
||||
"name": r[1],
|
||||
"title": r[2],
|
||||
"state": r[3],
|
||||
"kind": r[4],
|
||||
"updated": r[5],
|
||||
"node_id": r[6],
|
||||
"user_id": r[7],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def list_project_attachments(self, project_id: str) -> list[dict[str, Any]]:
|
||||
"""Committed attachments referenced by any turn in the project's
|
||||
workstreams — metadata only (never the content blob), each with the
|
||||
first referencing ws_id (content serving is ws-scoped, so the caller
|
||||
needs a ws to build a download URL against).
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
ref_rows = conn.execute(
|
||||
sa.select(conversations.c.ws_id, conversations.c.attachments)
|
||||
.select_from(
|
||||
conversations.join(workstreams, workstreams.c.ws_id == conversations.c.ws_id)
|
||||
)
|
||||
.where(
|
||||
workstreams.c.project_id == project_id,
|
||||
conversations.c.attachments.is_not(None),
|
||||
)
|
||||
.order_by(conversations.c.id)
|
||||
).fetchall()
|
||||
first_ws: dict[str, str] = {}
|
||||
for ws_id, raw in ref_rows:
|
||||
try:
|
||||
ids = json.loads(raw) if raw else []
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not isinstance(ids, list):
|
||||
continue
|
||||
for aid in ids:
|
||||
if isinstance(aid, str) and aid and aid not in first_ws:
|
||||
first_ws[aid] = ws_id
|
||||
if not first_ws:
|
||||
return []
|
||||
# Chunk the IN() — a project can reference more distinct
|
||||
# blobs than the driver's bind-parameter cap.
|
||||
meta: dict[str, Any] = {}
|
||||
ids = list(first_ws)
|
||||
for i in range(0, len(ids), 500):
|
||||
meta_rows = conn.execute(
|
||||
sa.select(
|
||||
workstream_attachments.c.attachment_id,
|
||||
workstream_attachments.c.filename,
|
||||
workstream_attachments.c.mime_type,
|
||||
workstream_attachments.c.size_bytes,
|
||||
workstream_attachments.c.kind,
|
||||
workstream_attachments.c.created,
|
||||
).where(workstream_attachments.c.attachment_id.in_(ids[i : i + 500]))
|
||||
).fetchall()
|
||||
for r in meta_rows:
|
||||
meta[r[0]] = r
|
||||
out: list[dict[str, Any]] = []
|
||||
for aid, ws_id in first_ws.items():
|
||||
m = meta.get(aid)
|
||||
if m is None:
|
||||
# Ref-list names a pruned blob (refcount GC) — skip.
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"attachment_id": m[0],
|
||||
"filename": m[1],
|
||||
"mime_type": m[2],
|
||||
"size_bytes": m[3],
|
||||
"kind": m[4],
|
||||
"created": m[5],
|
||||
"ws_id": ws_id,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
# -- OIDC identity ---------------------------------------------------------
|
||||
|
||||
def create_oidc_user(
|
||||
|
||||
@@ -590,7 +590,6 @@ SKILL_MUTABLE = frozenset(
|
||||
"argument_hint",
|
||||
}
|
||||
)
|
||||
STRUCTURED_MEMORY_MUTABLE = frozenset({"content", "description", "type"})
|
||||
# ``oauth_client_secret_ct`` is intentionally absent from this set. It has
|
||||
# its own dedicated writer (``StorageBackend.set_mcp_oauth_client_secret_ct``)
|
||||
# so the encrypt/None-to-clear semantics — owned by
|
||||
@@ -766,6 +765,12 @@ def reconstruct_messages(
|
||||
the user sees the actual partial state — refreshing during tool execution
|
||||
otherwise silently drops the trailing turn from the UI.
|
||||
"""
|
||||
# Drop compaction checkpoint markers: they are resume-only artifacts (the
|
||||
# persisted summary that lets a reopened session rehydrate a bounded context,
|
||||
# see reconstruct_turns_checkpointed), not real conversation turns, so
|
||||
# /history, export, and search show the true transcript without an injected
|
||||
# summary.
|
||||
rows = [r for r in rows if not _is_compaction_marker(r)]
|
||||
turns = reconstruct_turns(rows, ws_id, attachments_by_msg)
|
||||
if repair:
|
||||
turns = recover_trajectory(turns)
|
||||
@@ -981,3 +986,151 @@ def recover_trajectory(turns: list[Turn]) -> list[Turn]:
|
||||
break
|
||||
del turns[asst_idx:]
|
||||
return turns
|
||||
|
||||
|
||||
# -- Compaction checkpoints ---------------------------------------------------
|
||||
#
|
||||
# When a live session compacts, it swaps its in-memory history for a summary but
|
||||
# leaves the full transcript in storage. A persisted *checkpoint marker* lets a
|
||||
# reopened session rehydrate that same bounded view instead of the full history
|
||||
# (which can exceed the model window — e.g. a long session, or one switched to a
|
||||
# smaller-context model — and deadlock the first send). The marker is one
|
||||
# ``assistant`` row tagged ``_source="compaction"`` whose content is the summary
|
||||
# and whose ``meta`` carries ``{"watermark": <id>}``: every conversation row with
|
||||
# id <= the watermark was folded into the summary; rows after it are still live.
|
||||
|
||||
COMPACTION_SOURCE = "compaction"
|
||||
COMPACTION_SUMMARY_LABEL = "[Conversation summary]"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# History-search tenancy scope
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL mirror of ``WorkstreamProjectVisibility`` (core.auth) — THE statement of
|
||||
# who may see a workstream's rows. A conversation row is hidden from
|
||||
# ``:scope_user`` only when its workstream links to an EXISTING project whose
|
||||
# visibility is 'private' and the user is neither the workstream creator, the
|
||||
# project owner, nor a member. No project link, a dangling link (project row
|
||||
# deleted), and non-private projects all stay visible — the trusted-team
|
||||
# default. ``COALESCE(w.user_id, '')`` makes a NULL creator hide (not leak):
|
||||
# plain ``<>`` would go NULL and drop the row from the hide-subquery. Callers
|
||||
# never pass an empty ``:scope_user`` (empty scopes to None = unscoped), so
|
||||
# the COALESCE sentinel cannot collide with a real principal. Portable across
|
||||
# SQLite and PostgreSQL; expects the conversations table aliased ``c``.
|
||||
# ``tests/test_search_history_visibility.py`` pins parity with the Python
|
||||
# predicate — change either side only in lockstep.
|
||||
|
||||
HISTORY_VISIBILITY_SCOPE_SQL = (
|
||||
"AND NOT EXISTS ("
|
||||
" SELECT 1 FROM workstreams w"
|
||||
" JOIN projects p ON p.project_id = w.project_id"
|
||||
" WHERE w.ws_id = c.ws_id"
|
||||
" AND p.visibility = 'private'"
|
||||
" AND COALESCE(w.user_id, '') <> :scope_user"
|
||||
" AND p.owner_id <> :scope_user"
|
||||
" AND NOT EXISTS ("
|
||||
" SELECT 1 FROM project_members pm"
|
||||
" WHERE pm.project_id = w.project_id AND pm.user_id = :scope_user"
|
||||
" )"
|
||||
") "
|
||||
)
|
||||
|
||||
# Live-context exclusion for the model-facing recall tool: drop rows of ONE
|
||||
# workstream (the caller's own) above its compaction checkpoint — those rows
|
||||
# are the live segment, already in the model's context, and returning them
|
||||
# wastes result slots on duplicates. Rows at or below the checkpoint are the
|
||||
# summarized-away past: exactly what recall exists to re-derive.
|
||||
# ``:excl_after`` = the checkpoint boundary, or ``-1`` for a never-compacted
|
||||
# workstream — the whole conversation is live then, so the whole workstream
|
||||
# is excluded. Human-facing surfaces (the /history command) deliberately do
|
||||
# NOT apply this: a person browsing history has no "context" to duplicate.
|
||||
|
||||
HISTORY_CONTEXT_EXCLUSION_SQL = "AND NOT (c.ws_id = :excl_ws AND c.id > :excl_after) "
|
||||
|
||||
|
||||
def _is_compaction_marker(row: Any) -> bool:
|
||||
"""True when a stored row is a compaction checkpoint marker (``_source`` = row index 7)."""
|
||||
return len(row) > 7 and row[7] == COMPACTION_SOURCE
|
||||
|
||||
|
||||
def parse_checkpoint_watermark(meta_json: str | None) -> int | None:
|
||||
"""Parse a compaction marker's ``meta`` JSON into its watermark id.
|
||||
|
||||
The single decoder for the checkpoint boundary — shared by the resume
|
||||
slice (:func:`reconstruct_turns_checkpointed` via
|
||||
:func:`_compaction_watermark`) and the backends'
|
||||
``get_compaction_checkpoint``. Returns ``None`` for a marker that
|
||||
predates the watermark field or whose meta is malformed (callers fall
|
||||
back to safe behavior: resume loads the full transcript, recall excludes
|
||||
the whole workstream — never *less* safe than the honest answer)."""
|
||||
meta = _source_meta_from_json(meta_json)
|
||||
wm = meta.get("watermark") if meta else None
|
||||
return wm if isinstance(wm, int) and not isinstance(wm, bool) else None
|
||||
|
||||
|
||||
def _compaction_watermark(row: Any) -> int | None:
|
||||
"""Read a marker row's checkpoint watermark from its ``meta`` column (row index 10)."""
|
||||
return parse_checkpoint_watermark(row[10] if len(row) > 10 else None)
|
||||
|
||||
|
||||
def reconstruct_turns_checkpointed(
|
||||
rows: list[Any],
|
||||
ws_id: str,
|
||||
attachments_by_msg: dict[int, list[dict[str, Any]]] | None = None,
|
||||
*,
|
||||
checkpoint: bool = True,
|
||||
) -> list[Turn]:
|
||||
"""Resume-path reconstruction that honors a persisted compaction checkpoint.
|
||||
|
||||
The full-history twin :func:`reconstruct_turns` rehydrates every row — correct
|
||||
for a session that never compacted, but on a compacted one it reloads the
|
||||
whole pre-compaction transcript the live session had already summarized away,
|
||||
which can overflow the context window on reopen and deadlock the first send.
|
||||
|
||||
If a compaction marker is present, load only ``[summary] + [rows after its
|
||||
watermark]`` — the in-memory view the session held when it compacted. The
|
||||
summarized prefix and any older markers (id <= watermark) are dropped; the
|
||||
full history stays in storage for ``/history``/export/audit. The marker
|
||||
reconstructs as an ``assistant`` turn re-tagged ``source="compaction"``
|
||||
(``reconstruct_turns`` drops ``_source`` for assistant rows), and a
|
||||
synthetic ``[Conversation summary]`` user label — tagged likewise — is
|
||||
prepended to match what ``session._compact_messages`` builds in memory
|
||||
(and to satisfy the leading-user-turn wire contract). The tags keep
|
||||
provenance-testing consumers (``_find_turn_boundaries``, title gen)
|
||||
working identically across a reopen.
|
||||
|
||||
Falls back to the full reconstruction when there is no marker or its watermark
|
||||
is absent/corrupt, so every pre-checkpoint session loads exactly as before.
|
||||
|
||||
``checkpoint=False`` (export/audit): return the FULL transcript as Turns —
|
||||
every real row, no watermark slice — but still drop marker rows, since
|
||||
:func:`reconstruct_turns` does not filter them and a leaked marker would land
|
||||
as a stray ``assistant`` summary turn mid-history. This is the Turn-path twin
|
||||
of the marker filter :func:`reconstruct_messages` already applies on the dict
|
||||
(display) path; resume passes the default ``True``.
|
||||
"""
|
||||
marker = max((r for r in rows if _is_compaction_marker(r)), key=lambda r: r[0], default=None)
|
||||
watermark = _compaction_watermark(marker) if marker is not None else None
|
||||
if not checkpoint or marker is None or watermark is None:
|
||||
# Full transcript, marker rows dropped — three cases collapse here:
|
||||
# ``checkpoint=False`` (export/audit), no marker, and a malformed/legacy
|
||||
# watermark. ``reconstruct_turns`` does not filter markers, so a corrupt
|
||||
# marker would otherwise leak its summary as a stray ``assistant`` turn
|
||||
# mid-history (and a malformed marker must NOT slice — losing real
|
||||
# messages is worse than reloading the whole transcript).
|
||||
return reconstruct_turns(
|
||||
[r for r in rows if not _is_compaction_marker(r)], ws_id, attachments_by_msg
|
||||
)
|
||||
# Keep the marker (the summary) plus every non-marker row written after the
|
||||
# watermark — the preserved tail and everything since. Reconstruct the two
|
||||
# slices separately so the summary leads regardless of row-id ordering (a
|
||||
# preserved tail kept verbatim sits at a *lower* id than the marker).
|
||||
tail = [r for r in rows if r[0] > watermark and not _is_compaction_marker(r)]
|
||||
label = Turn(Role.USER, _content_blocks(COMPACTION_SUMMARY_LABEL, []), source=COMPACTION_SOURCE)
|
||||
marker_turns = reconstruct_turns([marker], ws_id, attachments_by_msg)
|
||||
for t in marker_turns:
|
||||
t.source = COMPACTION_SOURCE
|
||||
return [
|
||||
label,
|
||||
*marker_turns,
|
||||
*reconstruct_turns(tail, ws_id, attachments_by_msg),
|
||||
]
|
||||
|
||||
@@ -122,6 +122,7 @@ SYSTEM_TURN_SOURCES: Final = frozenset(
|
||||
"start",
|
||||
"tool_error",
|
||||
"repeat",
|
||||
"compaction_pending",
|
||||
"idle_children",
|
||||
"watch_triggered",
|
||||
}
|
||||
|
||||
@@ -316,17 +316,39 @@ def resolve_workstream_owner(
|
||||
"""Resolve ``ws_id`` to its owner; 404 when the row doesn't exist.
|
||||
|
||||
Turnstone is a trusted-team tool: scope-level auth (e.g.
|
||||
``admin.workstreams`` / ``admin.coordinator``) is the only gate;
|
||||
row-level ownership is not enforced here. Returns
|
||||
``admin.coordinator``) is the primary gate and row-level OWNERSHIP
|
||||
is still not enforced here. The one row-level check this performs
|
||||
is PROJECT tenancy: a workstream attached to a *private* project is
|
||||
only reachable by the project's owner/members, the workstream's own
|
||||
creator, service-scope callers, and ``admin.cluster.inspect``
|
||||
holders — everyone else gets a 403 (see
|
||||
:class:`turnstone.core.auth.WorkstreamProjectVisibility`). Returns
|
||||
``(owner_user_id, None)`` on success — the persisted owner id,
|
||||
which attachments should be filed under so existing storage shape
|
||||
is preserved. Falls back to the caller's own uid when the row has
|
||||
no recorded owner.
|
||||
|
||||
When ``mgr`` is provided and the workstream is live in memory,
|
||||
trust its cached ``user_id`` instead of round-tripping storage —
|
||||
keeps in-memory-only handlers functional during transient DB
|
||||
outages and trims the hot-path by one query.
|
||||
trust its cached ``user_id`` / ``project_id`` instead of
|
||||
round-tripping storage for the ROW — but note the project gate
|
||||
itself may still hit storage (one memoized ``get_project`` +
|
||||
membership lookup when the row names a project), and it fails
|
||||
CLOSED: a project-attached workstream 403s when that lookup fails
|
||||
rather than risking a leak. That is a deliberate trade — a DB
|
||||
outage already degrades sends/persistence, and failing open on a
|
||||
private-tenancy check is the worse failure. Project-less
|
||||
workstreams keep the zero-storage fast path.
|
||||
|
||||
Failure-mode map, precisely: which error a storage outage produces
|
||||
depends on where it strikes. The ROW lookup is fail-soft
|
||||
(:func:`turnstone.core.memory.get_workstream_row` degrades an
|
||||
exception to ``None``), so a storage-path row fetch that fails
|
||||
surfaces as this function's 404 — pre-existing behaviour shared
|
||||
with the old owner lookup. Only once a row IS resolved (from the
|
||||
manager or storage) does the project gate run, and THAT failure is
|
||||
the fail-closed 403 above. In-memory workstreams therefore 403 on
|
||||
a project-gate blip; not-loaded ones typically 404 at the row
|
||||
fetch first.
|
||||
|
||||
``not_found_label`` is the message body the 404 carries — the
|
||||
interactive surface uses "Workstream not found"; coord uses
|
||||
@@ -334,20 +356,37 @@ def resolve_workstream_owner(
|
||||
"""
|
||||
from starlette.responses import JSONResponse as _JSONResponse
|
||||
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
|
||||
caller = auth_user_id(request)
|
||||
owner: str | None = None
|
||||
project_id = ""
|
||||
|
||||
if mgr is not None:
|
||||
ws_mem = mgr.get(ws_id)
|
||||
if ws_mem is not None:
|
||||
return ws_mem.user_id or caller, None
|
||||
# Not in memory — fall through to storage so persisted-but-not-
|
||||
# loaded rows still resolve.
|
||||
owner = ws_mem.user_id or ""
|
||||
raw_pid = getattr(ws_mem, "project_id", "")
|
||||
project_id = raw_pid if isinstance(raw_pid, str) else ""
|
||||
|
||||
from turnstone.core.memory import get_workstream_owner
|
||||
|
||||
owner = get_workstream_owner(ws_id)
|
||||
if owner is None:
|
||||
return "", _JSONResponse({"error": not_found_label}, status_code=404)
|
||||
# Not in memory — storage resolves persisted-but-not-loaded rows.
|
||||
from turnstone.core.memory import get_workstream_row
|
||||
|
||||
row = get_workstream_row(ws_id)
|
||||
if row is None:
|
||||
return "", _JSONResponse({"error": not_found_label}, status_code=404)
|
||||
owner = row.get("user_id") or ""
|
||||
project_id = row.get("project_id") or ""
|
||||
|
||||
if project_id:
|
||||
visibility = WorkstreamProjectVisibility.for_request(request)
|
||||
if not visibility.ws_visible(project_id, ws_owner=owner):
|
||||
return "", _JSONResponse(
|
||||
{"error": "Forbidden: workstream belongs to a private project"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
return owner or caller, None
|
||||
|
||||
|
||||
|
||||
+119
-26
@@ -1065,12 +1065,25 @@ async def global_events_sse(request: Request) -> Response:
|
||||
|
||||
async def dashboard(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/dashboard — enriched workstream data + aggregate stats."""
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
from turnstone.core.memory import get_workstream_display_name
|
||||
|
||||
mgr: SessionManager = request.app.state.workstreams
|
||||
# No per-user filter — see list_workstreams above for the rationale
|
||||
# (trusted-team deployment shape; mutations stay owner-gated).
|
||||
wss = mgr.list_all()
|
||||
# No per-user OWNER filter (trusted-team deployment shape) — but
|
||||
# private-project rows are dropped for non-members, same predicate
|
||||
# as every other listing surface. Executor: the predicate resolves
|
||||
# project rows from storage, so the filter must not run on the
|
||||
# event loop.
|
||||
visibility = WorkstreamProjectVisibility.for_request(request)
|
||||
|
||||
def _visible_wss() -> list[Workstream]:
|
||||
return [
|
||||
ws
|
||||
for ws in mgr.list_all()
|
||||
if visibility.ws_visible(getattr(ws, "project_id", "") or "", ws_owner=ws.user_id or "")
|
||||
]
|
||||
|
||||
wss = await asyncio.to_thread(_visible_wss)
|
||||
total_tokens = 0
|
||||
total_tool_calls = 0
|
||||
active_count = 0
|
||||
@@ -1910,6 +1923,7 @@ async def _interactive_create_validate_request(
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
inherited_pid = False
|
||||
body_parent = body.get("parent_ws_id") or None
|
||||
if body_parent is not None:
|
||||
from turnstone.core.storage._registry import get_storage as _get_storage_for_parent
|
||||
@@ -1936,6 +1950,28 @@ async def _interactive_create_validate_request(
|
||||
# (which forwards the body verbatim).
|
||||
if not (body.get("project_id") or "") and parent_row.get("project_id"):
|
||||
body["project_id"] = parent_row.get("project_id")
|
||||
inherited_pid = True
|
||||
# Project attach gate (explicit or parent-inherited): a private
|
||||
# project accepts new workstreams only from its owner/members, and a
|
||||
# nonexistent EXPLICIT project_id is a caller error rather than a
|
||||
# silent dangling link. Re-checking the inherited value is deliberate
|
||||
# — a coordinator owner whose membership was revoked fails the child
|
||||
# spawn loudly here instead of minting rows they can no longer see.
|
||||
# The one asymmetry: an INHERITED project that no longer exists is
|
||||
# not the spawner's error — project deletion leaves the parent's
|
||||
# link dangling by design and must not disable spawn_workstream, so
|
||||
# the child simply isn't attached.
|
||||
attach_pid = str(body.get("project_id") or "")
|
||||
if attach_pid:
|
||||
from turnstone.core.auth import ensure_project_attachable
|
||||
|
||||
denied = ensure_project_attachable(uid, attach_pid)
|
||||
if denied is not None:
|
||||
status, message = denied
|
||||
if inherited_pid and status == 400:
|
||||
body["project_id"] = ""
|
||||
else:
|
||||
return JSONResponse({"error": message}, status_code=status)
|
||||
notify_targets_raw = body.get("notify_targets", "[]")
|
||||
if isinstance(notify_targets_raw, list):
|
||||
notify_targets_raw = json.dumps(notify_targets_raw)
|
||||
@@ -2062,6 +2098,12 @@ async def _interactive_create_post_install(
|
||||
# isolation — a coordinator must never receive
|
||||
# child_ws_* events for workstreams it doesn't own.
|
||||
"user_id": ws.user_id,
|
||||
# Project id likewise: the console's per-connection SSE
|
||||
# tenancy filter gates ws_created on it — omitting it
|
||||
# here made freshly-created private-project workstreams
|
||||
# fail open on live cluster views (its open/resume and
|
||||
# node-snapshot siblings already carry it).
|
||||
"project_id": ws.project_id,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2529,11 +2571,15 @@ async def save_memory(request: Request) -> JSONResponse:
|
||||
{"error": f"content exceeds {_MAX_MEMORY_CONTENT} character limit"},
|
||||
status_code=400,
|
||||
)
|
||||
description = str(body.get("description", ""))
|
||||
mem_type = str(body.get("type", "general"))
|
||||
# None (field omitted) means "leave unset": the upsert keeps the stored
|
||||
# value on update and defaults on insert; an explicit value overwrites.
|
||||
raw_desc = body.get("description")
|
||||
description = None if raw_desc is None else str(raw_desc)
|
||||
raw_type = body.get("type")
|
||||
mem_type = None if raw_type is None else str(raw_type)
|
||||
scope = str(body.get("scope", "global"))
|
||||
scope_id = str(body.get("scope_id", ""))
|
||||
if mem_type not in _VALID_MEMORY_TYPES:
|
||||
if mem_type is not None and mem_type not in _VALID_MEMORY_TYPES:
|
||||
return JSONResponse(
|
||||
{"error": f"invalid type: {mem_type}; must be one of {sorted(_VALID_MEMORY_TYPES)}"},
|
||||
status_code=400,
|
||||
@@ -2550,26 +2596,13 @@ async def save_memory(request: Request) -> JSONResponse:
|
||||
err = _validate_scope_scope_id(scope, scope_id, require_scope_id=True)
|
||||
if err:
|
||||
return err
|
||||
# save_structured_memory normalises the name internally
|
||||
from turnstone.core.memory import normalize_key
|
||||
|
||||
normalized_name = normalize_key(name)
|
||||
memory_id, old_content = save_structured_memory(
|
||||
# The upsert RETURNINGs the full saved row, so no follow-up read is needed.
|
||||
row, was_update = save_structured_memory(
|
||||
name, content, description=description, mem_type=mem_type, scope=scope, scope_id=scope_id
|
||||
)
|
||||
if not memory_id:
|
||||
if not row:
|
||||
return JSONResponse({"error": "Failed to save memory"}, status_code=500)
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
mem = storage.get_structured_memory(memory_id) if storage else None
|
||||
if not mem:
|
||||
return JSONResponse(
|
||||
{"memory_id": memory_id, "name": normalized_name, "status": "saved"},
|
||||
status_code=201,
|
||||
)
|
||||
status_code = 200 if old_content is not None else 201
|
||||
return JSONResponse(mem, status_code=status_code)
|
||||
return JSONResponse(row, status_code=200 if was_update else 201)
|
||||
|
||||
|
||||
async def search_memories(request: Request) -> JSONResponse:
|
||||
@@ -2735,6 +2768,47 @@ async def get_project_endpoint(request: Request) -> JSONResponse:
|
||||
return JSONResponse(_project_view(row))
|
||||
|
||||
|
||||
async def project_resources_endpoint(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/projects/{project_id}/resources — the project's contents.
|
||||
|
||||
Workstreams, referenced attachments (metadata + a ws_id to build the
|
||||
ws-scoped download URL against), and the project-scoped memory count —
|
||||
the aggregate view behind the manage → governance → Projects shelf.
|
||||
Same access gate as ``get_project_endpoint``: ``project.read`` plus the
|
||||
per-project ACL.
|
||||
"""
|
||||
from turnstone.core.auth import require_permission, user_can_access_project
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
err = require_permission(request, "project.read")
|
||||
if err:
|
||||
return err
|
||||
uid, uerr = _project_request_uid(request)
|
||||
if uerr:
|
||||
return uerr
|
||||
project_id = request.path_params["project_id"]
|
||||
storage = get_storage()
|
||||
row = storage.get_project(project_id) if storage else None
|
||||
if row is None:
|
||||
return JSONResponse({"error": "project not found"}, status_code=404)
|
||||
if not user_can_access_project(uid, project_id, write=False, storage=storage):
|
||||
return JSONResponse({"error": "forbidden"}, status_code=403)
|
||||
|
||||
def _collect() -> dict[str, Any]:
|
||||
# The attachment scan walks every conversation row in the
|
||||
# project's workstreams — keep the whole collection off the
|
||||
# event loop.
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"name": row.get("name", ""),
|
||||
"workstreams": storage.list_workstreams_for_project(project_id),
|
||||
"attachments": storage.list_project_attachments(project_id),
|
||||
"memory_count": storage.count_structured_memories(scope="project", scope_id=project_id),
|
||||
}
|
||||
|
||||
return JSONResponse(await asyncio.to_thread(_collect))
|
||||
|
||||
|
||||
async def update_project_endpoint(request: Request) -> JSONResponse:
|
||||
"""PATCH /v1/api/projects/{project_id} — rename / re-visibility / archive."""
|
||||
from turnstone.core.auth import require_permission, user_can_access_project
|
||||
@@ -3178,7 +3252,12 @@ def _strip_server_status_for_read(full: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def _public_server_status(mcp_mgr: Any, name: str) -> dict[str, Any]:
|
||||
"""Strip ``command``/``url`` from ``get_server_status`` before returning over the wire."""
|
||||
return _strip_server_status(mcp_mgr.get_server_status(name))
|
||||
# aggregate=True: the operator refresh/reconnect endpoints are approve-scoped
|
||||
# cluster actions with no single requesting user, so oauth_user servers report
|
||||
# the any-user warm-pool view (matching the admin console) rather than the
|
||||
# per-user default — which, with user_id=None, would render a warm, in-use
|
||||
# server as disconnected/empty right after a successful refresh.
|
||||
return _strip_server_status(mcp_mgr.get_server_status(name, aggregate=True))
|
||||
|
||||
|
||||
def internal_mcp_status(request: Request) -> JSONResponse:
|
||||
@@ -3197,11 +3276,21 @@ def internal_mcp_status(request: Request) -> JSONResponse:
|
||||
if mcp_mgr is None:
|
||||
return JSONResponse({"servers": {}})
|
||||
|
||||
# Scope oauth_user server status to the requesting user — their per-user pool
|
||||
# catalog (and its existence) must not leak to other read-scoped callers.
|
||||
# _auth_user_id returns "" when unauthenticated, which the manager treats as
|
||||
# "no user context" (oauth_user servers then report not-connected). Callers
|
||||
# holding admin.mcp (the console cluster-health view, whose proxy forwards
|
||||
# the admin's permissions) get the cross-user aggregate instead — they are
|
||||
# already trusted to see consent counts + server config, so it is no new
|
||||
# disclosure and it keeps the operator "in use by anyone" pill working.
|
||||
auth_result = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
is_admin = auth_result is not None and auth_result.has_permission("admin.mcp")
|
||||
all_status = mcp_mgr.get_all_server_status(_auth_user_id(request), aggregate=is_admin)
|
||||
return JSONResponse(
|
||||
{
|
||||
"servers": {
|
||||
name: _strip_server_status_for_read(status)
|
||||
for name, status in mcp_mgr.get_all_server_status().items()
|
||||
name: _strip_server_status_for_read(status) for name, status in all_status.items()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -4241,6 +4330,10 @@ def create_app(
|
||||
delete_project_endpoint,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
Route(
|
||||
"/api/projects/{project_id}/resources",
|
||||
project_resources_endpoint,
|
||||
),
|
||||
Route(
|
||||
"/api/projects/{project_id}/members",
|
||||
list_project_members_endpoint,
|
||||
|
||||
@@ -47,6 +47,15 @@ if (_authChannel) {
|
||||
};
|
||||
}
|
||||
|
||||
// Raw-fetch callers (the SSE-error probes, which must inspect a 401's status
|
||||
// without authFetch's throw-on-401 contract) route a version_mismatch body
|
||||
// here so the post-re-login reload still picks up the new assets — the same
|
||||
// flag+overlay path authFetch takes below.
|
||||
export function noteVersionMismatch() {
|
||||
_authUpgradeReload = true;
|
||||
showLogin("upgrade");
|
||||
}
|
||||
|
||||
export async function authFetch(url, opts) {
|
||||
const maxRetries = 2;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
@@ -813,4 +822,5 @@ Object.assign(window, {
|
||||
hideLogin,
|
||||
logout,
|
||||
initLogin,
|
||||
noteVersionMismatch,
|
||||
});
|
||||
|
||||
@@ -59,6 +59,16 @@ function _ctxCell(sess) {
|
||||
|
||||
/* NAME cell: ellipsised title + an optional skill chip when the workstream
|
||||
launched with a non-default skill (empty for "Use defaults"). */
|
||||
/* Resolve a project_id to its display name via the shared projects data
|
||||
layer (window bridge — cards.js also loads in the classic bundles).
|
||||
Unknown / inaccessible ids render empty so callers can show "—". */
|
||||
function _projectName(projectId) {
|
||||
if (!projectId) return "";
|
||||
var tp = window.TurnstoneProjects;
|
||||
if (!tp || typeof tp.projectName !== "function") return "";
|
||||
return tp.projectName(projectId) || "";
|
||||
}
|
||||
|
||||
function _nameCell(sess) {
|
||||
var wrap = document.createElement("div");
|
||||
wrap.className = "scell-name";
|
||||
@@ -112,6 +122,20 @@ export var SavedColumns = {
|
||||
},
|
||||
};
|
||||
},
|
||||
project: function () {
|
||||
return {
|
||||
key: "project",
|
||||
label: "PROJECT",
|
||||
width: "120px",
|
||||
hideBelow: true,
|
||||
cell: function (s) {
|
||||
return _projectName(s.project_id) || "—";
|
||||
},
|
||||
sort: function (s) {
|
||||
return (_projectName(s.project_id) || "").toLowerCase();
|
||||
},
|
||||
};
|
||||
},
|
||||
count: function (field, label, width) {
|
||||
return {
|
||||
key: field,
|
||||
@@ -280,6 +304,8 @@ export function createSavedTable(opts) {
|
||||
" " +
|
||||
(sess.name || "") +
|
||||
" " +
|
||||
(_projectName(sess.project_id) || "") +
|
||||
" " +
|
||||
sess.ws_id
|
||||
).toLowerCase();
|
||||
return hay.indexOf(state.filter) !== -1;
|
||||
|
||||
@@ -95,6 +95,9 @@ export function createQueueController(opts) {
|
||||
typeof opts.onAfterDequeue === "function" ? opts.onAfterDequeue : null;
|
||||
var onIdle = typeof opts.onIdle === "function" ? opts.onIdle : null;
|
||||
var onNotice = typeof opts.onNotice === "function" ? opts.onNotice : null;
|
||||
// Live queued bubbles — the idle sweep iterates this instead of querying
|
||||
// the whole messages container (see onIdleEdge).
|
||||
var _liveQueued = new Set();
|
||||
// Upper bound on the dequeue DELETE so a wedged proxied node (the exact
|
||||
// case this flow targets) can't leave a card stuck "dismissing" forever.
|
||||
var DELETE_TIMEOUT_MS = 15000;
|
||||
@@ -199,6 +202,7 @@ export function createQueueController(opts) {
|
||||
host.appendChild(dismiss);
|
||||
|
||||
messagesEl.appendChild(el);
|
||||
_liveQueued.add(el);
|
||||
_scrollIntoView();
|
||||
return el;
|
||||
}
|
||||
@@ -319,6 +323,7 @@ export function createQueueController(opts) {
|
||||
}
|
||||
|
||||
function remove(el) {
|
||||
_liveQueued.delete(el);
|
||||
if (el && el.parentNode) el.remove();
|
||||
}
|
||||
|
||||
@@ -329,6 +334,7 @@ export function createQueueController(opts) {
|
||||
// cancelled, so present it as sent. If the user had clicked × first
|
||||
// (dismissAttempted), tell them it was too late.
|
||||
function _promote(el) {
|
||||
_liveQueued.delete(el);
|
||||
var attempted = el.dataset.dismissAttempted;
|
||||
el.classList.remove("msg-queued", "msg-queued-important");
|
||||
delete el.dataset.msgId;
|
||||
@@ -351,8 +357,19 @@ export function createQueueController(opts) {
|
||||
// onIdle hook so the consumer can run edge-only cleanup (e.g. clearing
|
||||
// cancel/force-stop timers).
|
||||
function onIdleEdge() {
|
||||
var queued = messagesEl.querySelectorAll(".msg-queued:not([aria-busy])");
|
||||
queued.forEach(_promote);
|
||||
// Sweep the controller-local live set, not the DOM: the old
|
||||
// ".msg-queued:not([aria-busy])" query walked every element under the
|
||||
// messages container (O(transcript) per busy→idle edge) to find the
|
||||
// handful of queued bubbles that always sit in the tail. Bubbles wiped
|
||||
// by a full re-render prune lazily via the isConnected check.
|
||||
_liveQueued.forEach(function (el) {
|
||||
if (!el.isConnected) {
|
||||
_liveQueued.delete(el);
|
||||
return;
|
||||
}
|
||||
if (el.hasAttribute("aria-busy")) return; // mid-dequeue — let it settle
|
||||
_promote(el);
|
||||
});
|
||||
if (onIdle) onIdle();
|
||||
}
|
||||
|
||||
|
||||
@@ -293,6 +293,16 @@
|
||||
.conv-diff-warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
/* Preview-omission notice — rendered as a SIBLING below the .conv-row-diff
|
||||
scroll box (never inside it, where the 240px fold hides it). Neutral ink,
|
||||
not --warn: informational omission, and AA-safe on both themes. */
|
||||
.conv-diff-omit {
|
||||
margin-top: 2px;
|
||||
padding: 2px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* Verdict badge — interactive's rich shape (risk + rec + conf, expandable
|
||||
detail) in the coordinator's neutral idiom. Risk drives the left-stripe
|
||||
@@ -706,3 +716,126 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Task-agent card — a task_agent .conv-row hosts a collapsible body of its
|
||||
sub-tool steps so they nest under the call instead of scattering top-level.
|
||||
Accent rail + inset background read as "inside" the task agent. */
|
||||
.conv-agent {
|
||||
margin: 8px 0 2px;
|
||||
border-left: 2px solid color-mix(in srgb, var(--accent) 45%, var(--hair-2));
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.conv-agent-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 28px; /* the card's primary control — a real hit target */
|
||||
padding: 4px 10px;
|
||||
background: none;
|
||||
border: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.conv-agent-toggle:hover {
|
||||
color: var(--ink-2);
|
||||
background: color-mix(in srgb, var(--ink) 5%, transparent);
|
||||
}
|
||||
.conv-agent-toggle:focus-visible {
|
||||
/* Match the house focus ring (.conv-btn / .conv-verdict-expand); inset
|
||||
because the toggle is full-width, flush to the card edge. */
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.conv-agent-caret {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.conv-agent[data-collapsed="true"] .conv-agent-caret {
|
||||
transform: rotate(-90deg); /* ▾ -> ▸, from the one collapse attribute */
|
||||
}
|
||||
.conv-agent[data-collapsed="true"] .conv-agent-body {
|
||||
display: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.conv-agent-caret {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.conv-agent-label {
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
/* Card-level liveness as a NON-colour cue (WCAG 1.4.1): a text suffix, not just
|
||||
a stripe — in a parallel batch the parent rail belongs to the whole group,
|
||||
not to this one task agent. */
|
||||
.conv-agent[data-state="running"] .conv-agent-label::after {
|
||||
content: " · running";
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.conv-agent[data-state="done"] .conv-agent-label::after {
|
||||
content: " · done";
|
||||
color: color-mix(in srgb, var(--ok) 70%, var(--ink-2));
|
||||
}
|
||||
.conv-agent[data-state="error"] .conv-agent-label::after {
|
||||
content: " · failed";
|
||||
color: var(--err);
|
||||
}
|
||||
.conv-agent-body {
|
||||
border-top: 1px solid var(--hair);
|
||||
}
|
||||
.conv-agent-body:empty {
|
||||
border-top: 0; /* no orphan hairline before the first step paints */
|
||||
}
|
||||
/* Nested step rows read a touch lighter than top-level tool rows. */
|
||||
.conv-agent-body .conv-row {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.conv-agent-body .conv-row + .conv-row {
|
||||
border-top: 1px solid var(--hair);
|
||||
}
|
||||
/* The card lives inside a .conv-row; in a PARALLEL batch the rail tick + dot
|
||||
(.conv-batch--parallel .conv-row::before/::after) are descendant rules that
|
||||
would re-draw on every nested step, striking through the names. Suppress
|
||||
them — the card's own accent rail already signals grouping. */
|
||||
.conv-agent-body .conv-row::before,
|
||||
.conv-agent-body .conv-row::after {
|
||||
content: none;
|
||||
}
|
||||
.conv-batch--parallel .conv-agent-body .conv-row {
|
||||
padding-left: 10px;
|
||||
}
|
||||
/* A nested sub-tool's approval gate binds to ITS step, not the whole card:
|
||||
drop the right-floating spacer so Deny/Approve sit under the step's command. */
|
||||
.conv-agent-body .conv-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
.conv-agent-body .conv-actions .conv-actions-spacer {
|
||||
display: none;
|
||||
}
|
||||
/* The streaming-output box is a sibling between nested rows; match the card's
|
||||
type scale + rail, and restore the row separators it sits between. */
|
||||
.conv-agent-body .tool-output-stream {
|
||||
font-size: 11px;
|
||||
border-left-color: color-mix(in srgb, var(--accent) 45%, var(--hair-2));
|
||||
}
|
||||
.conv-agent-body .conv-row + .tool-output-stream,
|
||||
.conv-agent-body .tool-output-stream + .conv-row {
|
||||
border-top: 1px solid var(--hair);
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.conv-agent-toggle {
|
||||
min-height: 44px; /* WCAG 2.5.5 touch target */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,10 +273,19 @@ export function buildConvCmd(item) {
|
||||
if (item.preview) {
|
||||
const diff = document.createElement("div");
|
||||
diff.className = "conv-row-diff";
|
||||
const lines = stripAnsi(item.preview).split("\n");
|
||||
const nodes = [];
|
||||
// The preview is uncapped upstream (a whole multiline command / one line
|
||||
// per edited line) — cap what we RENDER: past ~400 lines the preview
|
||||
// carries no decision value, the DOM cost is ~2 nodes/line in every
|
||||
// transcript row, and an argument-spread append of an unbounded node
|
||||
// list can throw RangeError mid-paint (engines cap spread arity around
|
||||
// 65k args), killing the tool card — and the approval gate — for the
|
||||
// batch. Appended incrementally for the same reason.
|
||||
const MAX_PREVIEW_LINES = 400;
|
||||
let lines = stripAnsi(item.preview).split("\n");
|
||||
const omitted = lines.length - MAX_PREVIEW_LINES;
|
||||
if (omitted > 0) lines = lines.slice(0, MAX_PREVIEW_LINES);
|
||||
lines.forEach((line, i) => {
|
||||
if (i > 0) nodes.push("\n");
|
||||
if (i > 0) diff.appendChild(document.createTextNode("\n"));
|
||||
const trimmed = line.trim();
|
||||
let cls = null;
|
||||
if (trimmed.startsWith("-")) cls = "conv-diff-del";
|
||||
@@ -286,13 +295,25 @@ export function buildConvCmd(item) {
|
||||
const span = document.createElement("span");
|
||||
span.className = cls;
|
||||
span.textContent = line;
|
||||
nodes.push(span);
|
||||
diff.appendChild(span);
|
||||
} else {
|
||||
nodes.push(line);
|
||||
diff.appendChild(document.createTextNode(line));
|
||||
}
|
||||
});
|
||||
diff.append(...nodes);
|
||||
frag.appendChild(diff);
|
||||
// The omission notice sits BELOW the scroll box as a sibling, not as the
|
||||
// diff's last child: .conv-row-diff is a 240px inner scroller, so an
|
||||
// inline marker would sit thousands of pixels below its fold — invisible
|
||||
// exactly at the approval moment, where the operator must know the
|
||||
// preview is partial. Its own neutral class (not .conv-diff-warn):
|
||||
// an omission is informational, not a command warning, and raw --warn
|
||||
// fails AA on the light panel background.
|
||||
if (omitted > 0) {
|
||||
const more = document.createElement("div");
|
||||
more.className = "conv-diff-omit";
|
||||
more.textContent = "… " + omitted + " more preview lines not shown";
|
||||
frag.appendChild(more);
|
||||
}
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
@@ -596,8 +617,72 @@ export function buildConvResult(output, opts) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clamp the rendered body — same rationale as the JSON pretty-print cap
|
||||
// above: the server ships tool output verbatim, and a single multi-MB
|
||||
// result (an agent cat-ing a large file) becomes a multi-MB pre-wrap text
|
||||
// node that stalls layout on insert and is rebuilt on every full
|
||||
// re-render. The transcript shows the head; the full output stays in
|
||||
// history/storage.
|
||||
const RAW_CAP = 64 * 1024;
|
||||
if (pretty.length > RAW_CAP) {
|
||||
pretty =
|
||||
pretty.slice(0, RAW_CAP) +
|
||||
"\n… (" +
|
||||
pretty.length.toLocaleString() +
|
||||
" chars total — truncated for display)";
|
||||
}
|
||||
const body = document.createElement("span");
|
||||
body.textContent = pretty;
|
||||
block.appendChild(body);
|
||||
return block;
|
||||
}
|
||||
|
||||
// Expandable body for a task_agent row's nested sub-steps (the "agent card").
|
||||
// A task agent runs its own sub-tools; this gives that row a collapsible body
|
||||
// the pane renders the live step stream into, so the steps nest UNDER the
|
||||
// task_agent call instead of scattering at the top level. The pane appends the
|
||||
// returned `wrap` into the task_agent .conv-row and renders step rows (ordinary
|
||||
// .conv-row leaves, matched by call_id) into `body`; `label` shows the live
|
||||
// step count. House style: programmatic DOM, textContent only.
|
||||
let _agentCardSeq = 0;
|
||||
|
||||
export function buildAgentCardBody() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "conv-agent";
|
||||
// Single source of truth for collapse: the data attribute drives BOTH the
|
||||
// body visibility and the caret rotation (in CSS), so a programmatic toggle
|
||||
// (collapse-by-default here, or the auto-expand-on-approval in the pane) can't
|
||||
// desync caret/aria the way per-node textContent did.
|
||||
//
|
||||
// Collapsed by default: a task agent can run 100+ steps, and the parent often
|
||||
// fans out many in parallel — expanded, that's a wall. The label carries the
|
||||
// live count + state ("12 steps · running"), so you expand on demand. The
|
||||
// pane force-expands a card when a nested approval is pending (it's blocking —
|
||||
// it can't hide behind the toggle).
|
||||
wrap.dataset.collapsed = "true";
|
||||
const bodyId = "conv-agent-body-" + (_agentCardSeq += 1);
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "conv-agent-toggle";
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
toggle.setAttribute("aria-controls", bodyId);
|
||||
toggle.setAttribute("aria-label", "Show or hide sub-agent steps");
|
||||
const caret = document.createElement("span");
|
||||
caret.className = "conv-agent-caret";
|
||||
caret.setAttribute("aria-hidden", "true");
|
||||
caret.textContent = "▾"; // CSS rotates it when collapsed
|
||||
const label = document.createElement("span");
|
||||
label.className = "conv-agent-label";
|
||||
label.textContent = "0 steps";
|
||||
toggle.append(caret, label);
|
||||
const body = document.createElement("div");
|
||||
body.className = "conv-agent-body";
|
||||
body.id = bodyId;
|
||||
toggle.addEventListener("click", () => {
|
||||
const collapsed = wrap.dataset.collapsed === "true";
|
||||
wrap.dataset.collapsed = collapsed ? "false" : "true";
|
||||
toggle.setAttribute("aria-expanded", collapsed ? "true" : "false");
|
||||
});
|
||||
wrap.append(toggle, body);
|
||||
return { wrap, body, label };
|
||||
}
|
||||
|
||||
@@ -38,22 +38,56 @@
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Trimmed from 5px: the .msg turn boxes already carry a 4px margin-bottom, so
|
||||
a 5px flex gap stacked ~9px of dead space between segments ("too thick").
|
||||
2px gap + the 4px element margin lands at a compact ~6px between turns. */
|
||||
gap: 2px;
|
||||
/* BLOCK flow, deliberately not a flex column: a column flexbox relayouts
|
||||
ALL items when the streaming row's height changes — O(rows) per token at
|
||||
long-session scale — where block flow dirties only the appended tail.
|
||||
Block flow also retires the flex min-height:auto squish hazard the old
|
||||
per-child flex-shrink pin existed to suppress. Inter-row rhythm moves
|
||||
to the sibling margin below (2px + the .msg 4px margin-bottom lands at
|
||||
the same compact ~6px between turns as the old 2px gap). */
|
||||
/* Native scroll anchoring is pure overhead here: the pane owns bottom
|
||||
pinning (isNearBottom + rAF pin), and during streaming the anchor node
|
||||
the browser picks sits inside the innerHTML-replaced live bubble —
|
||||
forcing anchor re-selection every frame and double-adjusting against
|
||||
our pin. */
|
||||
overflow-anchor: none;
|
||||
}
|
||||
/* The message list is a SCROLLING flex column (overflow-y:auto), so its children
|
||||
must size to content and never shrink. Without this, an `overflow:hidden`
|
||||
card — the .conv-batch tool block — has its flex `min-height:auto` resolve to
|
||||
0 and gets squished to a ~2px stripe (just its border) once the column fills,
|
||||
while plain .msg blocks (overflow visible) keep their height. That asymmetry
|
||||
is the "tool calls collapse to an empty stripe" regression; pinning every row
|
||||
makes the column scroll instead. */
|
||||
.pane--embedded .pane-messages > * {
|
||||
flex-shrink: 0;
|
||||
.pane--embedded .pane-messages > * + * {
|
||||
margin-top: 2px;
|
||||
}
|
||||
/* Off-screen rows skip style/layout/paint entirely; contain-intrinsic-size's
|
||||
`auto` keyword remembers each row's last-rendered size, so scrollHeight
|
||||
(and the bottom pin) stays stable once a row has painted — the estimate
|
||||
only covers never-rendered rows during upward scrubbing. The last two
|
||||
children are exempt: the live tail (streaming bubble / filling tool batch)
|
||||
mutates constantly and must never toggle skip-state mid-stream. */
|
||||
.pane--embedded .pane-messages > .msg:not(:nth-last-child(-n + 2)) {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 80px;
|
||||
}
|
||||
.pane--embedded .pane-messages > .conv-batch:not(:nth-last-child(-n + 2)) {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 200px;
|
||||
}
|
||||
/* "Load earlier" pager — the windowed transcript's top affordance. A real
|
||||
button (keyboard/AT reachable); quiet dashed chrome so it reads as an
|
||||
affordance, not a message row. */
|
||||
.msg-history-pager {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: var(--panel-2);
|
||||
color: var(--fg-dim);
|
||||
border: 1px dashed var(--hair);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg-history-pager:hover,
|
||||
.msg-history-pager:focus-visible {
|
||||
color: var(--fg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.pane--embedded .ws-status-bar {
|
||||
flex-shrink: 0;
|
||||
@@ -139,6 +173,11 @@
|
||||
max-height: 400px;
|
||||
animation: stream-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-output-stream {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-output.collapsed {
|
||||
max-height: 150px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -261,11 +261,31 @@ function _langToCssClass(lang) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main markdown renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hard cap on renderMarkdown re-entrancy. Blockquote/callout/list bodies
|
||||
// recurse through renderMarkdown; a pathological input (a few KB of nested
|
||||
// "> " prefixes) would otherwise overflow the call stack mid-render — an
|
||||
// exception the streaming callers can only partially recover from. Beyond
|
||||
// the cap the nested body renders as escaped plain text: degraded, visible.
|
||||
var _MD_MAX_DEPTH = 100;
|
||||
|
||||
export function renderMarkdown(text) {
|
||||
// Scope footnote IDs per top-level render call (prevents collisions across messages)
|
||||
if (_fnDepth >= _MD_MAX_DEPTH) {
|
||||
return "<p>" + escapeHtml(String(text == null ? "" : text)) + "</p>";
|
||||
}
|
||||
// Scope footnote IDs per top-level render call (prevents collisions across
|
||||
// messages). Depth accounting rides a try/finally: a throw anywhere in the
|
||||
// body used to strand _fnDepth elevated, freezing _fnScopeId so footnote
|
||||
// anchor ids collided across every later message.
|
||||
if (_fnDepth === 0) _fnScopeId++;
|
||||
_fnDepth++;
|
||||
try {
|
||||
return _renderMarkdownBody(text);
|
||||
} finally {
|
||||
_fnDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderMarkdownBody(text) {
|
||||
// Pre-pass: extract blockquote blocks and recursively render.
|
||||
// Must run FIRST (before code/math protection) so the recursive call
|
||||
// processes raw markdown, not text with outer-scope placeholders.
|
||||
@@ -742,7 +762,6 @@ export function renderMarkdown(text) {
|
||||
return inlineMaths[parseInt(idx)];
|
||||
});
|
||||
|
||||
_fnDepth--;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1119,37 +1138,72 @@ function _renderMermaidBlock(container, callback) {
|
||||
return;
|
||||
}
|
||||
_mermaidPending.set(source, [container]);
|
||||
_mermaidRenderChain = _mermaidRenderChain.then(function () {
|
||||
var pending = _mermaidPending.get(source) || [];
|
||||
_mermaidPending.delete(source);
|
||||
var id = "mermaid-" + ++_mermaidIdCounter;
|
||||
return mermaid.render(id, source).then(
|
||||
function (result) {
|
||||
_cacheFifoEntry(
|
||||
_mermaidSvgCache,
|
||||
source,
|
||||
{ svg: result.svg, bindFunctions: result.bindFunctions },
|
||||
_MERMAID_CACHE_MAX,
|
||||
);
|
||||
for (var i = 0; i < pending.length; i++) {
|
||||
var c = pending[i];
|
||||
if (c.isConnected) {
|
||||
_applyMermaidSvg(c, result.svg, result.bindFunctions);
|
||||
// ``linkPending`` is hoisted to the link's closure so the rejection-proof
|
||||
// .catch below can paint the error on the containers THIS link captured —
|
||||
// by the time it runs, the link already removed them from _mermaidPending,
|
||||
// so without the hoist they'd sit at "Loading diagram…" forever.
|
||||
var linkPending = null;
|
||||
_mermaidRenderChain = _mermaidRenderChain
|
||||
.then(function () {
|
||||
var pending = _mermaidPending.get(source) || [];
|
||||
linkPending = pending;
|
||||
_mermaidPending.delete(source);
|
||||
var id = "mermaid-" + ++_mermaidIdCounter;
|
||||
return mermaid.render(id, source).then(
|
||||
function (result) {
|
||||
_cacheFifoEntry(
|
||||
_mermaidSvgCache,
|
||||
source,
|
||||
{ svg: result.svg, bindFunctions: result.bindFunctions },
|
||||
_MERMAID_CACHE_MAX,
|
||||
);
|
||||
for (var i = 0; i < pending.length; i++) {
|
||||
var c = pending[i];
|
||||
if (c.isConnected) {
|
||||
// Per-container guard: one bad apply (a bindFunctions throw)
|
||||
// must not skip the remaining containers for this source.
|
||||
try {
|
||||
_applyMermaidSvg(c, result.svg, result.bindFunctions);
|
||||
} catch (e) {
|
||||
_applyMermaidError(c, source, "diagram apply failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
function (err) {
|
||||
var orphan = document.getElementById(id);
|
||||
if (orphan) orphan.remove();
|
||||
var msg = err && err.message ? err.message : "Diagram error";
|
||||
_cacheFifoEntry(_mermaidErrorCache, source, msg, _MERMAID_CACHE_MAX);
|
||||
for (var i = 0; i < pending.length; i++) {
|
||||
var c = pending[i];
|
||||
if (c.isConnected) _applyMermaidError(c, source, msg);
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(function (e) {
|
||||
// Rejection-proof every link: a sync throw escaping the link body
|
||||
// (e.g. mermaid.render throwing on malformed input before returning a
|
||||
// promise) would otherwise reject the shared chain, and every later
|
||||
// diagram would silently sit at "Loading diagram…" forever. Settle
|
||||
// back to fulfilled and paint the error on the containers this link
|
||||
// had already claimed. Deliberately NO _mermaidPending.delete(source)
|
||||
// here: the link deleted its own entry up top, and any entry present
|
||||
// NOW belongs to a newer re-entry for the same source — deleting it
|
||||
// would orphan THAT link's containers.
|
||||
var msg = (e && e.message) || "Diagram error";
|
||||
_cacheFifoEntry(_mermaidErrorCache, source, msg, _MERMAID_CACHE_MAX);
|
||||
(linkPending || []).forEach(function (c) {
|
||||
if (!c.isConnected) return;
|
||||
try {
|
||||
_applyMermaidError(c, source, msg);
|
||||
} catch (_) {
|
||||
/* container-level failure — nothing left to degrade to */
|
||||
}
|
||||
},
|
||||
function (err) {
|
||||
var orphan = document.getElementById(id);
|
||||
if (orphan) orphan.remove();
|
||||
var msg = err && err.message ? err.message : "Diagram error";
|
||||
_cacheFifoEntry(_mermaidErrorCache, source, msg, _MERMAID_CACHE_MAX);
|
||||
for (var i = 0; i < pending.length; i++) {
|
||||
var c = pending[i];
|
||||
if (c.isConnected) _applyMermaidError(c, source, msg);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
console.warn("renderer: mermaid render chain error", e);
|
||||
});
|
||||
if (callback) callback();
|
||||
}
|
||||
|
||||
@@ -1253,19 +1307,32 @@ export function reRenderAllMermaid() {
|
||||
// ---------------------------------------------------------------------------
|
||||
function _streamingRenderApply(el, buffer) {
|
||||
if (el._lastRenderedBuffer === buffer) return;
|
||||
try {
|
||||
el.innerHTML = renderMarkdown(buffer);
|
||||
} catch (e) {
|
||||
// A render failure must not wedge the stream: show THIS frame as plain
|
||||
// text and leave the buffer UN-marked, so the next delta / the finalize
|
||||
// pass re-attempts a full render (partial-input throws heal themselves
|
||||
// once the closing tokens arrive). Marking before the render used to
|
||||
// make an errored frame look done — the finalize short-circuit then
|
||||
// pinned the stale DOM forever.
|
||||
console.warn("renderer: streaming render failed; plain-text frame", e);
|
||||
el.textContent = buffer;
|
||||
return;
|
||||
}
|
||||
el._lastRenderedBuffer = buffer;
|
||||
var html = renderMarkdown(buffer);
|
||||
el.innerHTML = html;
|
||||
// Progressive hljs + mermaid render — see comment above. Both are
|
||||
// no-ops when the element has no matching code blocks, and their
|
||||
// source-keyed caches avoid re-tokenizing / re-rendering for
|
||||
// sources we've already processed. Subsequent rAF ticks that
|
||||
// re-extract the same closed fence hit the cache synchronously.
|
||||
if (typeof postRenderHljs === "function") {
|
||||
// Guarded: decoration failures degrade to undecorated markup, never to
|
||||
// a broken segment state upstream.
|
||||
try {
|
||||
postRenderHljs(el);
|
||||
}
|
||||
if (typeof postRenderMermaid === "function") {
|
||||
postRenderMermaid(el);
|
||||
} catch (e) {
|
||||
console.warn("renderer: post-render decoration failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ export function showToast(message, type) {
|
||||
const el = document.getElementById("toast");
|
||||
if (!el) return;
|
||||
if (_toastShowing) {
|
||||
// Coalesce + cap: the queue drains at one toast per ~3.3s, so any
|
||||
// sustained source (verdict toasts during an auto-approved tool storm)
|
||||
// would otherwise grow it for the rest of the session and keep
|
||||
// surfacing hours-stale notices. Identical consecutive messages
|
||||
// collapse; beyond the cap the OLDEST queued toast drops (newest wins —
|
||||
// it reflects current state).
|
||||
const last = _toastQueue[_toastQueue.length - 1];
|
||||
if (last && last.message === message && last.type === type) return;
|
||||
if (_toastQueue.length >= 5) _toastQueue.shift();
|
||||
_toastQueue.push({ message: message, type: type });
|
||||
return;
|
||||
}
|
||||
|
||||
+141
-8
@@ -849,6 +849,7 @@ let _wsTable = null;
|
||||
function _initSavedWsTable() {
|
||||
const WS_COLUMNS = [
|
||||
SavedColumns.name(),
|
||||
SavedColumns.project(),
|
||||
SavedColumns.model(),
|
||||
SavedColumns.count("message_count", "MSGS"),
|
||||
SavedColumns.ctx(),
|
||||
@@ -884,6 +885,13 @@ function _initSavedWsTable() {
|
||||
},
|
||||
},
|
||||
});
|
||||
// The PROJECT column resolves names from the shared projects cache,
|
||||
// which fills asynchronously — re-render once names arrive.
|
||||
if (window.TurnstoneProjects) {
|
||||
window.TurnstoneProjects.onProjectsChange(function () {
|
||||
if (_wsTable) _wsTable.render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// HTML inline-onclick wrappers — keep the global names the existing markup
|
||||
@@ -1519,8 +1527,34 @@ function connectGlobalSSE() {
|
||||
if (globalEvtSource && globalEvtSource.lastEventId) {
|
||||
globalLastEventId = globalEvtSource.lastEventId;
|
||||
}
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === "ws_state") {
|
||||
// Guarded parse: the cursor above has already advanced past this frame,
|
||||
// so a parse failure is a permanently-lost roster mutation — resync the
|
||||
// roster from REST instead of silently drifting (a dropped ws_created
|
||||
// renders as a conversation that never appears; a dropped ws_closed as
|
||||
// a ghost row forever).
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch (err) {
|
||||
console.warn("global SSE: malformed frame — resyncing roster", err);
|
||||
resyncRoster();
|
||||
return;
|
||||
}
|
||||
if (data.type === "node_snapshot") {
|
||||
// Recovery floor: the server emits this when our resume cursor
|
||||
// predates its ring buffer (fresh connect, or a truncated gap after
|
||||
// hidden-tab/sleep). The snapshot carries the FULL workstream
|
||||
// inventory — rebuild the roster wholesale; per-ws panes re-sync
|
||||
// through their own Tier-2 streams. Eviction is safe here (and only
|
||||
// here): the snapshot is serialized with ws_created/ws_closed on the
|
||||
// stream itself.
|
||||
applyRosterSnapshot(data.workstreams || [], { evict: true });
|
||||
} else if (data.type === "replay_truncated") {
|
||||
// Events between our cursor and the buffer head are gone for good.
|
||||
// The node_snapshot that follows rebuilds the roster; refetch too so
|
||||
// recovery doesn't depend on event ordering.
|
||||
resyncRoster();
|
||||
} else if (data.type === "ws_state") {
|
||||
updateTabIndicator(data.ws_id, data.state, {
|
||||
tokens: data.tokens,
|
||||
context_ratio: data.context_ratio,
|
||||
@@ -2057,6 +2091,93 @@ document.addEventListener("keydown", function (e) {
|
||||
// 16. Init
|
||||
// ===========================================================================
|
||||
|
||||
// Rebuild the roster from a node_snapshot payload (workstream items keyed by
|
||||
// ``id`` — the snapshot mirrors the console-collector projection, not the
|
||||
// REST list's ``ws_id``). ``opts.evict``: remove roster entries missing
|
||||
// from the list and close their panes. Eviction is ONLY safe for the
|
||||
// in-stream node_snapshot — it is serialized with ws_created/ws_closed on
|
||||
// the SSE stream, so it can't race a roster mutation. An out-of-band REST
|
||||
// snapshot (resyncRoster) can be built server-side BEFORE a create whose
|
||||
// ws_created the client already consumed; evicting from it would close a
|
||||
// live, freshly-opened conversation. REST resyncs therefore merge only;
|
||||
// missed-ws_closed ghosts heal on the next in-stream snapshot.
|
||||
function applyRosterSnapshot(list, opts) {
|
||||
const evict = !!(opts && opts.evict);
|
||||
// Null-prototype membership map: a ws id that happened to collide with an
|
||||
// Object.prototype property name would read as always-seen on a plain
|
||||
// object and dodge eviction.
|
||||
const seen = Object.create(null);
|
||||
(list || []).forEach(function (ws) {
|
||||
if (!ws || !ws.id) return;
|
||||
seen[ws.id] = true;
|
||||
const cur = workstreams[ws.id] || {};
|
||||
cur.name = ws.name || cur.name || ws.id.slice(0, 6);
|
||||
cur.state = ws.state || cur.state || "idle";
|
||||
cur.parent_ws_id = ws.parent_ws_id || null;
|
||||
cur.project_id = ws.project_id || null;
|
||||
workstreams[ws.id] = cur;
|
||||
});
|
||||
if (evict) {
|
||||
const pm = window.TS_SHELL && window.TS_SHELL.panes;
|
||||
// Stable key snapshot: mutating the roster mid-walk is well-defined for
|
||||
// the currently-visited key, but the snapshot makes the eviction loop
|
||||
// self-evidently order-safe and skips inherited keys.
|
||||
for (const id of Object.keys(workstreams)) {
|
||||
if (!seen[id]) {
|
||||
// Gap recovery can retire a session the user is LOOKING at — the
|
||||
// live ws_closed (and its eviction toast) is exactly what was missed
|
||||
// during the gap — so closing the pane wordlessly would yank it
|
||||
// mid-read. Toast only when an open pane goes away; mass ghost-row
|
||||
// cleanup in the rail stays quiet.
|
||||
const wasOpen = !!(pm && pm.hasPane("interactive", id));
|
||||
const name =
|
||||
(workstreams[id] && workstreams[id].name) || id.slice(0, 6);
|
||||
delete workstreams[id];
|
||||
closeSessionPane(id);
|
||||
if (wasOpen) showToast("Session ended: " + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
fireRender();
|
||||
}
|
||||
|
||||
// REST fallback for the same recovery (replay_truncated / a malformed frame
|
||||
// whose cursor already advanced). MERGE-ONLY (see applyRosterSnapshot) and
|
||||
// gated on r.ok — a 503 during a node restart parses as a JSON error body
|
||||
// with no ``workstreams``, which must not read as an authoritative empty
|
||||
// roster. In-flight latch: one resync at a time — repeated triggers during
|
||||
// an outage must not stack fetches.
|
||||
let _rosterResyncInflight = null;
|
||||
function resyncRoster() {
|
||||
if (_rosterResyncInflight) return _rosterResyncInflight;
|
||||
_rosterResyncInflight = authFetch("/v1/api/workstreams")
|
||||
.then(function (r) {
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.workstreams) return;
|
||||
applyRosterSnapshot(
|
||||
data.workstreams.map(function (ws) {
|
||||
return {
|
||||
id: ws.ws_id,
|
||||
name: ws.name,
|
||||
state: ws.state,
|
||||
parent_ws_id: ws.parent_ws_id,
|
||||
project_id: ws.project_id,
|
||||
};
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(function () {
|
||||
/* transient — the next snapshot or reconnect heals the roster */
|
||||
})
|
||||
.finally(function () {
|
||||
_rosterResyncInflight = null;
|
||||
});
|
||||
return _rosterResyncInflight;
|
||||
}
|
||||
|
||||
function initWorkstreams() {
|
||||
return authFetch("/v1/api/workstreams")
|
||||
.then(function (r) {
|
||||
@@ -2222,15 +2343,27 @@ window.addEventListener("popstate", function (e) {
|
||||
|
||||
// Rail re-render fan-out — the rail subscribes via TS_APP.onRender; every
|
||||
// roster mutation calls fireRender() so the Workspaces section stays live.
|
||||
// rAF-coalesced: the server emits ws_state at least twice per tool round for
|
||||
// EVERY workstream on the node, and each subscriber repaint rebuilds the
|
||||
// whole rail (replaceChildren + a listener per row) — uncoalesced, a busy
|
||||
// session drove thousands of full rebuilds per hour, O(#workstreams) each.
|
||||
// All subscribers are snapshot-driven repaints, so batching to one repaint
|
||||
// per frame is lossless.
|
||||
const _renderSubs = [];
|
||||
let _renderScheduled = false;
|
||||
function fireRender() {
|
||||
for (const cb of _renderSubs) {
|
||||
try {
|
||||
cb();
|
||||
} catch (e) {
|
||||
console.error("TS_APP render subscriber failed", e);
|
||||
if (_renderScheduled) return;
|
||||
_renderScheduled = true;
|
||||
requestAnimationFrame(function () {
|
||||
_renderScheduled = false;
|
||||
for (const cb of _renderSubs) {
|
||||
try {
|
||||
cb();
|
||||
} catch (e) {
|
||||
console.error("TS_APP render subscriber failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Open / focus an interactive session as a pane (base="" local transport — the
|
||||
|
||||
@@ -78,11 +78,13 @@
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* No flexbox gap — .msg supplies its own margin-bottom for inter-card
|
||||
spacing. A 14px gap here was stacking with the 4px card
|
||||
margin-bottom and pushing the per-card spacing to ~18px. */
|
||||
/* Block flow + no native scroll anchoring — mirrors the shared
|
||||
interactive.css scroller (which also carries the per-row
|
||||
content-visibility rules): a flex column relayouts every row on each
|
||||
streaming height change, and native anchoring fights the pane's own
|
||||
bottom pin. .msg supplies its own margin-bottom for inter-card
|
||||
spacing. */
|
||||
overflow-anchor: none;
|
||||
}
|
||||
/* .msg (shared_static/chat.css) provides padding / border / radius /
|
||||
line-height / word-wrap / margin / background. The interactive UI
|
||||
|
||||
Reference in New Issue
Block a user