diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml
index a69eafb404..3db5c6baaa 100644
--- a/.github/workflows/auto-tag-on-release-pr-merge.yml
+++ b/.github/workflows/auto-tag-on-release-pr-merge.yml
@@ -45,7 +45,7 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
@@ -111,8 +111,8 @@ jobs:
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
- PR_PUSHER: ${{ github.event.pull_request.head.user.login }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
+ MERGED_AT: ${{ github.event.pull_request.merged_at }}
run: |
VERSION="${VERSION#desktop-v}"
export VERSION
@@ -147,7 +147,17 @@ jobs:
exit 1
fi
fi
- gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
+ if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/$TAG" \
-f sha="$TARGET_SHA" \
- --silent
+ --silent; then
+ # Ref creation is atomic. A concurrent retry may have won the race;
+ # accept that only when it created the exact immutable ref.
+ EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)"
+ if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
+ echo "Tag $TAG was concurrently created at $TARGET_SHA"
+ exit 0
+ fi
+ echo "::error::Tag creation failed and $TAG resolves to $EXISTING_SHA (expected $TARGET_SHA)"
+ exit 1
+ fi
diff --git a/.github/workflows/benchmark-harbor.yml b/.github/workflows/benchmark-harbor.yml
index 31efe933c5..6024f00575 100644
--- a/.github/workflows/benchmark-harbor.yml
+++ b/.github/workflows/benchmark-harbor.yml
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bc594e16ad..e65157705a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,7 +28,7 @@ jobs:
web: ${{ steps.filter.outputs.web }}
mobile: ${{ steps.filter.outputs.mobile }}
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 2
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
@@ -96,7 +96,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
@@ -117,7 +117,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
@@ -139,7 +139,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
@@ -235,7 +235,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Get pnpm store directory
id: pnpm-cache
@@ -318,7 +318,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
# Reuse the relay binaries and backend test archive when none of their
# inputs changed (desktop-only PRs hit this every time). The key covers
@@ -391,7 +391,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Start integration services
run: |
@@ -580,7 +580,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Install cargo-nextest
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
@@ -692,6 +692,18 @@ jobs:
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
+ - name: Workspace profile (kind:9033) gate tests
+ # Call-site integration for the 9033 authorization gate: open relay
+ # rosterless/steward transitions and the closed-relay admin/owner rule,
+ # against real Postgres. #[ignore]d in the default suite, selected
+ # explicitly here — see handlers::relay_admin::tests.
+ run: |
+ cargo nextest run \
+ --archive-file target/ci/backend-integration-tests.tar.zst \
+ -E 'package(buzz-relay) and test(/handlers::relay_admin::tests/)' \
+ --run-ignored ignored-only
+ env:
+ DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: NIP-ER reminder e2e
# Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path
# validation, author-only read filtering, and scheduler delivery against
@@ -704,6 +716,17 @@ jobs:
--run-ignored ignored-only
env:
RELAY_URL: ws://localhost:3000
+ - name: NIP-MP coordinate deletion guard
+ # Verifies the never-delete-newer invariant of soft_delete_by_coordinate:
+ # a stale tombstone (created_at earlier than the live head) spares that
+ # head, and an equal-timestamp tombstone deletes it.
+ run: |
+ cargo nextest run \
+ --archive-file target/ci/backend-integration-tests.tar.zst \
+ -E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \
+ --run-ignored ignored-only
+ env:
+ DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: Upload relay log
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -721,7 +744,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
@@ -739,7 +762,7 @@ jobs:
./scripts/start-relay-for-tests.sh --no-build
- name: Relay E2E tests
run: |
- cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture
+ cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
env:
@@ -762,7 +785,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
@@ -797,7 +820,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
@@ -858,7 +881,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Dependency policy
run: cargo-deny check
@@ -870,7 +893,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Check for dead API token references in client code
run: |
# Fail if dead API token patterns reappear in desktop, mobile, docs, or config.
@@ -899,7 +922,7 @@ jobs:
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-musl
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
@@ -936,7 +959,7 @@ jobs:
env:
TARGET: x86_64-pc-windows-msvc
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows
# runner — hermit, used by the Linux jobs, does not provide MSVC. The
# toolchain (1.95.0 + clippy via profile = default) comes from the
@@ -1000,7 +1023,7 @@ jobs:
git log -1 --format=%s | grep -qx smoke
echo "Host bash resolved and functional; git commit round-trip passed"
- name: Check (Tauri crate)
- run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
+ run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target $env:TARGET
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Test (Tauri crate)
@@ -1017,7 +1040,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
@@ -1031,6 +1054,7 @@ jobs:
mkdir -p desktop/src-tauri/binaries
touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
+ touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET"
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
touch "desktop/src-tauri/binaries/buzz-$TARGET"
diff --git a/.github/workflows/desktop-release-cache-proof.yml b/.github/workflows/desktop-release-cache-proof.yml
new file mode 100644
index 0000000000..cf9c8e7827
--- /dev/null
+++ b/.github/workflows/desktop-release-cache-proof.yml
@@ -0,0 +1,164 @@
+name: Desktop release cache tag-scope proof
+
+# Dispatch from a cache-proof-* tag at the same trusted-main SHA warmed by all
+# four canaries. Every job restores only and requires an exact cache hit.
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ macos:
+ name: Prove macOS ${{ matrix.target }} cache visibility
+ if: github.repository == 'block/buzz'
+ runs-on: macos-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: aarch64-apple-darwin
+ features: mesh-llm
+ - target: x86_64-apple-darwin
+ features: default
+ steps:
+ - name: Require cache proof tag
+ run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }'
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+ - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
+ - name: Patch proof dependency graph
+ run: |
+ cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof"
+ cd src-tauri && cargo update --workspace
+ - name: Resolve native toolchain identity
+ id: native_toolchain
+ run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT"
+ - name: Compute exact release cache key
+ id: rust_cache_key
+ env:
+ CACHE_TARGET: ${{ matrix.target }}
+ CACHE_FEATURES: ${{ matrix.features }}
+ NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
+ run: |
+ KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target "$CACHE_TARGET" --features "$CACHE_FEATURES" --native-inputs "$NATIVE_TOOLCHAIN_ID")
+ echo "key=$KEY" >> "$GITHUB_OUTPUT"
+ - name: Restore exact default-branch cache from tag
+ id: rust_cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
+ - name: Require exact cache hit
+ env:
+ CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }}
+ CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }}
+ EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }}
+ run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }'
+
+ linux:
+ name: Prove Linux cache visibility
+ if: github.repository == 'block/buzz'
+ runs-on: ubuntu-latest
+ container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
+ timeout-minutes: 15
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Require cache proof tag and install release native tools
+ run: |
+ [[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }
+ apt-get update
+ apt-get install -y --no-install-recommends build-essential ca-certificates curl git libasound2-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf pkg-config
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+ - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+ - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
+ - name: Patch proof dependency graph
+ run: |
+ cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof"
+ cd src-tauri && cargo update --workspace
+ - name: Resolve native toolchain identity
+ id: native_toolchain
+ run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT"
+ - name: Compute exact release cache key
+ id: rust_cache_key
+ env:
+ NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
+ run: |
+ KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-unknown-linux-gnu --features mesh-llm --native-inputs "$NATIVE_TOOLCHAIN_ID")
+ echo "key=$KEY" >> "$GITHUB_OUTPUT"
+ - name: Restore exact default-branch cache from tag
+ id: rust_cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
+ - name: Require exact cache hit
+ env:
+ CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }}
+ CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }}
+ EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }}
+ run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }'
+
+ windows:
+ name: Prove Windows cache visibility
+ if: github.repository == 'block/buzz'
+ runs-on: windows-latest
+ timeout-minutes: 15
+ steps:
+ - name: Require cache proof tag
+ shell: bash
+ run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }'
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+ - name: Patch proof dependency graph
+ shell: bash
+ run: |
+ cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof"
+ cd src-tauri && cargo update --workspace
+ - name: Resolve native toolchain identity
+ id: native_toolchain
+ shell: bash
+ run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT"
+ - name: Compute exact release cache key
+ id: rust_cache_key
+ shell: bash
+ env:
+ NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
+ run: |
+ KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-pc-windows-msvc --features default --native-inputs "$NATIVE_TOOLCHAIN_ID")
+ echo "key=$KEY" >> "$GITHUB_OUTPUT"
+ - name: Restore exact default-branch cache from tag
+ id: rust_cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
+ - name: Require exact cache hit
+ shell: bash
+ env:
+ CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }}
+ CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }}
+ EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }}
+ run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }'
diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml
new file mode 100644
index 0000000000..61ccc800af
--- /dev/null
+++ b/.github/workflows/desktop-release-candidate.yml
@@ -0,0 +1,28 @@
+name: Desktop Release Candidate
+
+on:
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+ pull-requests: read
+
+jobs:
+ validate:
+ name: Desktop Release Candidate
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Validate immutable desktop candidate
+ if: startsWith(github.event.pull_request.head.ref, 'version-bump/')
+ env:
+ GH_TOKEN: ${{ github.token }}
+ VERSION: ${{ github.event.pull_request.head.ref }}
+ run: |
+ VERSION="${VERSION#version-bump/}"
+ scripts/desktop_release.py validate --candidate HEAD --version "$VERSION" --repo "$GITHUB_REPOSITORY"
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 655c533b75..dbdc661905 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
@@ -360,7 +360,7 @@ jobs:
arch: arm64
steps:
- name: Checkout
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml
index e3d443d9f3..7118d16708 100644
--- a/.github/workflows/helm-chart.yml
+++ b/.github/workflows/helm-chart.yml
@@ -59,7 +59,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
# On chart-tag rescue dispatch, lint/render the tagged commit that the
# publish job will package, not whatever `main` is when the dispatch
@@ -119,7 +119,7 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
@@ -166,7 +166,7 @@ jobs:
packages: write # push the chart to GHCR
steps:
- name: Checkout
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
# On the rescue dispatch, build the tagged commit (github.ref is
# `main` there); on a tag push, the default ref is already the tag.
diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml
index 9806443378..d8b10032b2 100644
--- a/.github/workflows/linux-canary.yml
+++ b/.github/workflows/linux-canary.yml
@@ -7,8 +7,8 @@ name: Linux Canary
# Design notes vs. signed-macos-canary.yml:
# - fix-appimage.sh is run without signing env vars; the script detects
# their absence and skips re-signing, repacking only (documented inline).
-# - mold linker added (rui314/setup-mold) to reduce link time, matching
-# the Linux Rust CI jobs in ci.yml.
+# - Build tools match release.yml; cache keys derive the concrete linker and
+# native library identity rather than assuming the moving runner image.
# - pnpm store restore/save pattern mirrors ci.yml:149-196.
on:
workflow_dispatch:
@@ -83,18 +83,6 @@ jobs:
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
-
- # Rust cache covering both the workspace sidecar build and the Tauri
- # crate build. shared-key scoped to linux-canary-release so canary runs
- # warm each other without colliding with CI's debug-profile keys.
- - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- with:
- workspaces: |
- .
- desktop/src-tauri
- shared-key: linux-canary-release
-
- name: Install appimagetool
run: |
case "$(uname -m)" in
@@ -154,6 +142,38 @@ jobs:
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
+ - name: Resolve native toolchain identity
+ id: native_toolchain
+ run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT"
+
+ # Compute this after cargo update so the key describes the graph that is
+ # actually compiled. The helper normalizes only Buzz Desktop's release
+ # version, allowing a canary to warm an otherwise identical tag build.
+ - name: Compute exact release cache key
+ id: rust_cache_key
+ env:
+ NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
+ run: |
+ KEY=$(scripts/desktop-release-cache-key.py \
+ --platform "$RUNNER_OS" \
+ --target x86_64-unknown-linux-gnu \
+ --features mesh-llm \
+ --native-inputs "$NATIVE_TOOLCHAIN_ID")
+ echo "key=$KEY" >> "$GITHUB_OUTPUT"
+ echo "Release cache key: $KEY"
+
+ - name: Restore exact release Cargo cache
+ id: rust_cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
+
- name: Generate non-updating bundle config
run: |
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
@@ -166,11 +186,11 @@ jobs:
- name: Build sidecars
run: |
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
- name: Build Linux Tauri app
- run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --config src-tauri/tauri.canary.conf.json
+ run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.canary.conf.json
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
@@ -190,6 +210,24 @@ jobs:
fi
bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}"
+ - name: Measure release Cargo cache inputs
+ if: always()
+ run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
+
+ # Only this trusted, main-bound canary writes the cache. Excluding bundle
+ # output prevents installers from entering it.
+ - name: Save exact release Cargo cache
+ if: steps.rust_cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
+
- name: Save pnpm store cache
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
diff --git a/.github/workflows/macos-intel-canary.yml b/.github/workflows/macos-intel-canary.yml
new file mode 100644
index 0000000000..35b05313c9
--- /dev/null
+++ b/.github/workflows/macos-intel-canary.yml
@@ -0,0 +1,126 @@
+name: macOS Intel Canary
+
+# Produces an unsigned Intel DMG from trusted main. Its release-equivalent
+# Cargo state warms the distinct x86_64 release target without signing or
+# publishing anything.
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build macOS Intel canary
+ if: github.repository == 'block/buzz'
+ runs-on: macos-latest
+ timeout-minutes: 60
+ env:
+ TARGET: x86_64-apple-darwin
+ steps:
+ - name: Require main
+ env:
+ SOURCE_REF: ${{ github.ref }}
+ run: |
+ if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
+ echo "::error::Canary builds must run from main; got $SOURCE_REF"
+ exit 1
+ fi
+
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+
+ - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
+
+ - name: Add Rust target
+ run: rustup target add "$TARGET"
+
+ - name: Install desktop dependencies
+ run: just desktop-install-ci
+
+ - name: Derive and patch canary version
+ run: |
+ BASE_VERSION=$(node -p "require('./desktop/package.json').version")
+ VERSION="${BASE_VERSION%%-*}-intel-test.${GITHUB_RUN_NUMBER}"
+ cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
+ cd src-tauri && cargo update --workspace
+
+ - name: Resolve native toolchain identity
+ id: native_toolchain
+ run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT"
+
+ - name: Compute exact release cache key
+ id: rust_cache_key
+ env:
+ NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
+ run: |
+ KEY=$(scripts/desktop-release-cache-key.py \
+ --platform "$RUNNER_OS" \
+ --target "$TARGET" \
+ --features default \
+ --native-inputs "$NATIVE_TOOLCHAIN_ID")
+ echo "key=$KEY" >> "$GITHUB_OUTPUT"
+ echo "Release cache key: $KEY"
+
+ - name: Restore exact release Cargo cache
+ id: rust_cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
+
+ - name: Generate non-updating bundle config
+ run: |
+ cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
+ {"bundle":{"createUpdaterArtifacts":false,"macOS":{"minimumSystemVersion":"10.15"}}}
+ JSON
+
+ - name: Build Intel sidecars
+ run: |
+ cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ ./scripts/bundle-sidecars.sh "$TARGET"
+
+ - name: Build unsigned Intel DMG
+ run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --bundles dmg --config src-tauri/tauri.canary.conf.json
+ env:
+ CMAKE_POLICY_VERSION_MINIMUM: "3.5"
+ MACOSX_DEPLOYMENT_TARGET: "10.15"
+ CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
+ TAURI_BUNDLER_DMG_IGNORE_CI: "true"
+
+ - name: Locate fresh Intel DMG
+ id: artifact
+ run: |
+ DMG=$(find "desktop/src-tauri/target/${TARGET}/release/bundle/dmg" -name '*.dmg' -type f | head -1)
+ [[ -n "$DMG" ]] || { echo "::error::No Intel DMG found"; exit 1; }
+ echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
+
+ - name: Upload Intel canary
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: buzz-macos-intel-canary-${{ github.sha }}
+ path: ${{ steps.artifact.outputs.dmg }}
+ if-no-files-found: error
+ retention-days: 7
+
+ - name: Measure release Cargo cache inputs
+ if: always()
+ run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
+
+ - name: Save exact release Cargo cache
+ if: steps.rust_cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
diff --git a/.github/workflows/mesh-lifecycle.yml b/.github/workflows/mesh-lifecycle.yml
new file mode 100644
index 0000000000..4780083ba4
--- /dev/null
+++ b/.github/workflows/mesh-lifecycle.yml
@@ -0,0 +1,111 @@
+name: Mesh Lifecycle
+# Relay-driven mesh lifecycle smoke: membership → signed discovery notes →
+# relay-derived allowlist → join → CPU inference over QUIC → stranger denied
+# (relay membership rejection + no routed inference, with a differential
+# trusted-inference health proof so a dead serve node can't fake a denial).
+# Runs the full Buzz "shared compute" join story with three real mesh-llm
+# node processes on one runner, using the Buzz relay as the control plane
+# (no hand-carried invite tokens). Mirrors the shape mesh-llm's own CI uses
+# for its two-node smokes (tiny CPU model, one runner, real QUIC mesh).
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'crates/buzz-relay/examples/mesh_*.rs'
+ - 'crates/buzz-relay/Cargo.toml'
+ - 'crates/buzz-admin/**'
+ - 'crates/buzz-test-client/**'
+ - 'crates/buzz-ws-client/**'
+ - 'Cargo.lock'
+ - 'desktop/src-tauri/src/mesh_llm/**'
+ - 'scripts/ci-mesh-lifecycle-smoke.sh'
+ - 'scripts/start-relay-for-tests.sh'
+ - '.github/workflows/mesh-lifecycle.yml'
+ pull_request:
+ paths:
+ - 'crates/buzz-relay/examples/mesh_*.rs'
+ - 'crates/buzz-relay/Cargo.toml'
+ - 'crates/buzz-admin/**'
+ - 'crates/buzz-test-client/**'
+ - 'crates/buzz-ws-client/**'
+ - 'Cargo.lock'
+ - 'desktop/src-tauri/src/mesh_llm/**'
+ - 'scripts/ci-mesh-lifecycle-smoke.sh'
+ - 'scripts/start-relay-for-tests.sh'
+ - '.github/workflows/mesh-lifecycle.yml'
+ workflow_dispatch:
+
+concurrency:
+ group: mesh-lifecycle-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ lifecycle-smoke:
+ name: Relay-Driven Mesh Lifecycle Smoke
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+
+ - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
+
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+ with:
+ save-if: ${{ github.event_name != 'pull_request' }}
+
+ # The mesh-llm SDK downloads a signed native runtime (llama.cpp CPU
+ # build) on first init, and the serve node downloads the smoke model
+ # from HuggingFace on first run. Key on the lockfile so a mesh pin bump
+ # rolls the runtime cache; the model ref is stable.
+ - name: Restore mesh runtime + model caches
+ id: mesh-caches
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cache/mesh-llm/native-runtimes
+ ~/.cache/huggingface/hub
+ key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }}
+ restore-keys: |
+ mesh-lifecycle-${{ runner.os }}-smollm2-135m-
+
+ - name: Start integration services
+ run: |
+ for attempt in 1 2 3; do
+ if docker compose up -d postgres redis minio minio-init; then
+ break
+ fi
+ if [ "$attempt" -eq 3 ]; then
+ echo "docker compose up failed after 3 attempts" >&2
+ exit 1
+ fi
+ echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2
+ sleep $((attempt * 5))
+ done
+
+ - name: Run relay-driven mesh lifecycle smoke
+ run: ./scripts/ci-mesh-lifecycle-smoke.sh 2>&1 | tee /tmp/mesh-lifecycle-harness.log
+
+ - name: Save mesh runtime + model caches
+ if: github.ref == 'refs/heads/main' && steps.mesh-caches.outputs.cache-hit != 'true'
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cache/mesh-llm/native-runtimes
+ ~/.cache/huggingface/hub
+ key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }}
+
+ - name: Upload relay + harness logs
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: mesh-lifecycle-logs
+ path: |
+ /tmp/buzz-relay.log
+ /tmp/mesh-lifecycle-harness.log
+ if-no-files-found: ignore
diff --git a/.github/workflows/prepare-desktop-release.yml b/.github/workflows/prepare-desktop-release.yml
deleted file mode 100644
index 7cc480b93b..0000000000
--- a/.github/workflows/prepare-desktop-release.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-name: Prepare Desktop Release
-
-on:
- workflow_dispatch:
- inputs:
- version:
- description: Semver to prepare (for example 0.5.1)
- required: true
-
-env:
- RELEASE_AUTOMATION_NAME: Carl
- RELEASE_AUTOMATION_EMAIL: c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz
-
-jobs:
- prepare:
- if: github.repository == 'block/buzz'
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - name: Create short-lived release preparer token
- id: preparer
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
- with:
- client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }}
- private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }}
- permission-contents: write
- permission-pull-requests: write
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- with:
- fetch-depth: 0
- token: ${{ steps.preparer.outputs.token }}
- - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- - name: Prepare immutable candidate and open or update PR
- env:
- GH_TOKEN: ${{ steps.preparer.outputs.token }}
- VERSION: ${{ inputs.version }}
- run: scripts/prepare-desktop-release.sh "$VERSION"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 07951ef81d..9da067b74e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -36,7 +36,7 @@ jobs:
exit 1
fi
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
@@ -64,7 +64,7 @@ jobs:
env:
VERSION: ${{ needs.setup.outputs.version }}
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
fetch-depth: 0
@@ -91,7 +91,7 @@ jobs:
- name: Build sidecars
run: |
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
# Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
@@ -278,7 +278,7 @@ jobs:
VERSION: ${{ needs.setup.outputs.version }}
TARGET: x86_64-apple-darwin
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
fetch-depth: 0
@@ -308,7 +308,7 @@ jobs:
- name: Build sidecars
run: |
- cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh "$TARGET"
- name: Build unsigned Tauri app
@@ -495,7 +495,7 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends gh
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
fetch-depth: 0
@@ -563,7 +563,7 @@ jobs:
- name: Build sidecars
run: |
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
- name: Generate release config
@@ -573,7 +573,7 @@ jobs:
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
- name: Build Linux Tauri app
- run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --config src-tauri/tauri.release.conf.json
+ run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.release.conf.json
env:
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
@@ -666,7 +666,7 @@ jobs:
VERSION: ${{ needs.setup.outputs.version }}
TARGET: x86_64-pc-windows-msvc
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
fetch-depth: 0
@@ -795,7 +795,7 @@ jobs:
VERSION: ${{ needs.setup.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
fetch-depth: 0
@@ -948,5 +948,5 @@ jobs:
run: gh release edit "desktop-v${VERSION}" --draft=false
- name: Upload latest.json to rolling release last
- if: ${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }}
+ if: ${{ !contains(needs.setup.outputs.version, '-') }}
run: gh release upload buzz-desktop-latest latest.json --clobber
diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml
index fb0656028a..5957f4785d 100644
--- a/.github/workflows/signed-macos-canary.yml
+++ b/.github/workflows/signed-macos-canary.yml
@@ -34,16 +34,6 @@ jobs:
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- # Rust cache covering both the workspace sidecar build and the Tauri
- # crate build. shared-key scoped to macos-canary-release so canary runs
- # warm each other without colliding with CI's debug-profile keys.
- - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- with:
- workspaces: |
- .
- desktop/src-tauri
- shared-key: macos-canary-release
-
- name: Get pnpm store directory
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
@@ -78,6 +68,38 @@ jobs:
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
+ - name: Resolve native toolchain identity
+ id: native_toolchain
+ run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT"
+
+ # Compute this after cargo update so the key describes the graph that is
+ # actually compiled. The helper normalizes only Buzz Desktop's release
+ # version, allowing a canary to warm an otherwise identical tag build.
+ - name: Compute exact release cache key
+ id: rust_cache_key
+ env:
+ NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
+ run: |
+ KEY=$(scripts/desktop-release-cache-key.py \
+ --platform "$RUNNER_OS" \
+ --target aarch64-apple-darwin \
+ --features mesh-llm \
+ --native-inputs "$NATIVE_TOOLCHAIN_ID")
+ echo "key=$KEY" >> "$GITHUB_OUTPUT"
+ echo "Release cache key: $KEY"
+
+ - name: Restore exact release Cargo cache
+ id: rust_cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
+
- name: Generate non-updating bundle config
run: |
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
@@ -93,7 +115,7 @@ jobs:
- name: Build sidecars
run: |
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
# Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
@@ -210,6 +232,24 @@ jobs:
if-no-files-found: error
retention-days: 7
+ - name: Measure release Cargo cache inputs
+ if: always()
+ run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
+
+ # Only this trusted, main-bound canary writes the cache. Excluding bundle
+ # output prevents installers or signed artifacts from entering it.
+ - name: Save exact release Cargo cache
+ if: steps.rust_cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
+
- name: Save pnpm store cache
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
diff --git a/.github/workflows/sprig-image.yml b/.github/workflows/sprig-image.yml
new file mode 100644
index 0000000000..d40aed2a22
--- /dev/null
+++ b/.github/workflows/sprig-image.yml
@@ -0,0 +1,238 @@
+name: Sprig image
+
+# Builds and publishes the public agent container image as
+# ghcr.io//buzz-sprig (override with the GHCR_SPRIG_IMAGE repo
+# variable) — the digest-pinned box the Kubernetes backend deploys agents into
+# (see Dockerfile.sprig and docs/remote-agents.md).
+#
+# Strategy mirrors docker.yml (the relay image): each architecture builds on
+# its native runner, pushes to GHCR by digest, then a merge job stitches the
+# per-arch digests into one multi-arch manifest and attests provenance.
+# No QEMU emulation.
+#
+# Triggers:
+# - push to main (paths-filtered) → :main + :sha-<7>
+# - tag sprig-v* → semver family (shared with sprig.yml's
+# binary release — one tag versions both)
+# - pull_request (paths-filtered) → build only, no push
+# - workflow_dispatch → manual publish at the current ref
+#
+# NOTE: the first push creates the GHCR package PRIVATE by default. An org
+# admin must flip that package to public once (Package settings → Change
+# visibility). Subsequent pushes keep the visibility.
+
+on:
+ push:
+ branches: [main]
+ tags: ["sprig-v[0-9]*"]
+ paths:
+ - "Dockerfile.sprig"
+ - "scripts/sprig-entrypoint.sh"
+ - ".github/workflows/sprig-image.yml"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "rust-toolchain.toml"
+ - "crates/**"
+ pull_request:
+ paths:
+ - "Dockerfile.sprig"
+ - "scripts/sprig-entrypoint.sh"
+ - ".github/workflows/sprig-image.yml"
+ workflow_dispatch: {}
+
+concurrency:
+ group: sprig-image-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }}
+
+permissions: {}
+
+env:
+ # Single source of truth for the image name; override with the
+ # GHCR_SPRIG_IMAGE repo variable (same pattern as docker.yml). Forks publish
+ # to their own GHCR namespace — nobody but block can push to block's.
+ IMAGE_NAME: ${{ vars.GHCR_SPRIG_IMAGE != '' && vars.GHCR_SPRIG_IMAGE || format('ghcr.io/{0}/buzz-sprig', github.repository_owner) }}
+
+jobs:
+ build:
+ name: Build (${{ matrix.platform }})
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ attestations: write
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux/amd64
+ runner: ubuntu-24.04
+ arch: amd64
+ - platform: linux/arm64
+ runner: ubuntu-24.04-arm
+ arch: arm64
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+ with:
+ # Same OOM cap as docker.yml — Rust compiles blow the 7GB runner
+ # at buildkit's default parallelism of 4.
+ buildkitd-config-inline: |
+ [worker.oci]
+ max-parallelism = 2
+
+ - name: Log in to GHCR
+ # Pull requests are build-only and never receive registry credentials:
+ # in a fork, GITHUB_TOKEN is read-only even for a same-repo PR, so
+ # logging in there fails the job with `ghcr.io/v2/: denied`.
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
+ with:
+ images: ${{ env.IMAGE_NAME }}
+ # match=^sprig-v(.*)$ strips the tag prefix for the semver parser,
+ # exactly as docker.yml does for relay-v. :latest comes from
+ # flavor.latest=auto — stable semver only, never main pushes.
+ tags: |
+ type=ref,event=branch
+ type=sha,prefix=sha-,format=short
+ type=semver,pattern={{version}},match=^sprig-v(.*)$
+ type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$
+ labels: |
+ org.opencontainers.image.title=Buzz Sprig
+ org.opencontainers.image.description=Agent runtime image for Buzz remote agents (buzz-acp multicall + git + curl)
+ org.opencontainers.image.licenses=Apache-2.0
+
+ - name: Build and push by digest
+ id: build
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
+ with:
+ context: .
+ file: ./Dockerfile.sprig
+ platforms: ${{ matrix.platform }}
+ labels: ${{ steps.meta.outputs.labels }}
+ outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
+ cache-from: |
+ type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }}
+ cache-to: |
+ ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }}
+
+ - name: Export digest
+ if: github.event_name != 'pull_request'
+ env:
+ DIGEST: ${{ steps.build.outputs.digest }}
+ run: |
+ mkdir -p /tmp/digests
+ touch "/tmp/digests/${DIGEST#sha256:}"
+
+ - name: Upload digest
+ if: github.event_name != 'pull_request'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: sprig-digest-${{ matrix.arch }}
+ path: /tmp/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ merge:
+ name: Merge multi-arch manifest
+ if: github.event_name != 'pull_request'
+ runs-on: ubuntu-24.04
+ needs: build
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ attestations: write
+
+ steps:
+ - name: Download per-arch digests
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ path: /tmp/digests
+ pattern: sprig-digest-*
+ merge-multiple: true
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+
+ - name: Log in to GHCR
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
+ with:
+ images: ${{ env.IMAGE_NAME }}
+ # Must mirror the build job's tag matrix exactly (see docker.yml).
+ flavor: |
+ latest=auto
+ tags: |
+ type=ref,event=branch
+ type=sha,prefix=sha-,format=short
+ type=semver,pattern={{version}},match=^sprig-v(.*)$
+ type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$
+
+ - name: Create and push manifest list
+ id: manifest
+ working-directory: /tmp/digests
+ env:
+ IMAGE_NAME: ${{ env.IMAGE_NAME }}
+ META_TAGS: ${{ steps.meta.outputs.tags }}
+ run: |
+ set -euo pipefail
+ tags=()
+ while IFS= read -r tag; do
+ [ -n "$tag" ] && tags+=("-t" "$tag")
+ done <<< "$META_TAGS"
+
+ digests=()
+ for digest in *; do
+ digests+=("${IMAGE_NAME}@sha256:${digest}")
+ done
+
+ docker buildx imagetools create "${tags[@]}" "${digests[@]}"
+
+ first_tag=$(echo "$META_TAGS" | head -n1)
+ merged_digest=$(docker buildx imagetools inspect "$first_tag" \
+ --format '{{json .Manifest}}' | jq -r '.digest')
+ echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT"
+
+ - name: Attest provenance for the merged image
+ # Verify with: gh attestation verify oci://$IMAGE_NAME: --owner
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-name: ${{ env.IMAGE_NAME }}
+ subject-digest: ${{ steps.manifest.outputs.digest }}
+ push-to-registry: true
+
+ - name: Summary
+ env:
+ IMAGE_NAME: ${{ env.IMAGE_NAME }}
+ DIGEST: ${{ steps.manifest.outputs.digest }}
+ run: |
+ {
+ echo "### Sprig image published"
+ echo '```'
+ echo "${IMAGE_NAME}@${DIGEST}"
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/sprig.yml b/.github/workflows/sprig.yml
index d80e79d059..f89d5c4207 100644
--- a/.github/workflows/sprig.yml
+++ b/.github/workflows/sprig.yml
@@ -42,7 +42,7 @@ jobs:
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-musl
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
@@ -119,7 +119,7 @@ jobs:
permissions:
contents: write
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Download all Sprig artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -275,7 +275,7 @@ jobs:
permissions:
contents: write
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Download all Sprig artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml
index 29f74fa0f6..7093efd2dc 100644
--- a/.github/workflows/windows-canary.yml
+++ b/.github/workflows/windows-canary.yml
@@ -46,24 +46,9 @@ jobs:
shell: bash
run: rustup target add "$TARGET"
- # Rust cache covering both the workspace sidecar build and the Tauri
- # crate build. shared-key scoped to windows-canary-release so canary
- # runs warm each other without colliding with CI's debug-profile key
- # (CI windows job does clippy/check, not --release).
- - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- with:
- workspaces: |
- .
- desktop/src-tauri
- shared-key: windows-canary-release
-
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.1
- # Disable setup-node's built-in cache: we manage the pnpm store cache
- # explicitly below (restore before install, save after) to mirror the
- # pattern used by ci.yml and to keep caching logic consistent across
- # all three canary workflows.
package-manager-cache: false
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
@@ -108,6 +93,40 @@ jobs:
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
+ - name: Resolve native toolchain identity
+ id: native_toolchain
+ shell: bash
+ run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT"
+
+ # Compute this after cargo update so the key describes the graph that is
+ # actually compiled. The helper normalizes only Buzz Desktop's release
+ # version, allowing a canary to warm an otherwise identical tag build.
+ - name: Compute exact release cache key
+ id: rust_cache_key
+ shell: bash
+ env:
+ NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
+ run: |
+ KEY=$(scripts/desktop-release-cache-key.py \
+ --platform "$RUNNER_OS" \
+ --target x86_64-pc-windows-msvc \
+ --features default \
+ --native-inputs "$NATIVE_TOOLCHAIN_ID")
+ echo "key=$KEY" >> "$GITHUB_OUTPUT"
+ echo "Release cache key: $KEY"
+
+ - name: Restore exact release Cargo cache
+ id: rust_cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
+
- name: Generate non-updating bundle config
shell: bash
run: |
@@ -152,6 +171,25 @@ jobs:
if-no-files-found: error
retention-days: 7
+ - name: Measure release Cargo cache inputs
+ if: always()
+ shell: bash
+ run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
+
+ # Only this trusted, main-bound canary writes the cache. Excluding bundle
+ # output prevents installers from entering it.
+ - name: Save exact release Cargo cache
+ if: steps.rust_cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ desktop/src-tauri/target
+ !desktop/src-tauri/target/**/release/bundle
+ key: ${{ steps.rust_cache_key.outputs.key }}
+
- name: Save pnpm store cache
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
diff --git a/.gitignore b/.gitignore
index 65ddcaf1c4..f26e74136c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,10 @@
/dist/
/admin-web/dist/
+# Python cache
+__pycache__/
+*.pyc
+
# lefthook-generated hook scripts (machine-specific)
.hooks/
diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json
new file mode 100644
index 0000000000..cc2c267019
--- /dev/null
+++ b/.release/desktop-candidate.json
@@ -0,0 +1,8 @@
+{
+ "schema": 1,
+ "version": "0.5.5",
+ "base_sha": "25a9cf1be6d245fbd7373cb1160dbc790baf5bd5",
+ "previous_tag": "desktop-v0.5.4",
+ "tag": "desktop-v0.5.5",
+ "commit_count": 44
+}
diff --git a/AGENTS.md b/AGENTS.md
index 7ff0eb4d47..571871c3a4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,14 +13,14 @@ Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay,
| Repo | Purpose |
|------|---------|
| [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness |
-| [squareup/sprout-releases](https://github.com/squareup/sprout-releases) | Buildkite pipeline producing Block-signed macOS + iOS builds with `-block` version suffix |
+| [squareup/buzz-releases](https://github.com/squareup/buzz-releases) | Buildkite pipelines producing Block-signed macOS + iOS builds with `-block` desktop version suffix |
| [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR |
| [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster |
| [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay |
```
block/buzz (source)
- ├─► sprout-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)
+ ├─► buzz-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)
├─► sprout-oss (relay Docker image → ECR)
│ └─► block-coder-tf-stacks (Helm chart → ArgoCD → staging cluster)
└─── sprout-backend-blox (Blox compute provider for Desktop agent launch)
@@ -145,6 +145,10 @@ first, then implement handling in the relay.
**Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags.
Filters and queries must scope to `h` tags when operating within a channel.
+This applies to events *inside* a channel. Addressable events that describe a
+channel carry its id in their `d` tag instead: kind:39000 (metadata),
+kind:39001, kind:39002 (membership). `get_channels` resolves a user's channels
+from the `d` tag of their kind:39002 events, not from `h`.
**Agent-facing operations go in `buzz-cli`**: New agent-facing features belong in `buzz-cli` — add a subcommand there first, then wire the REST/WebSocket call in `client.rs`. `buzz-dev-mcp` (shell + file tools for `buzz-agent`) is separate.
@@ -503,6 +507,7 @@ reconnects preserve pending avatar verification work):
- `resetRenderScopedReactionHydration()` — reaction hydration cache
- `clearSearchHitEventCache()` — search result event cache
- `clearMarkdownNodeCache()` — markdown parse-node cache
+- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events)
**If you add a new module-level cache, Map, or class instance that holds
community-scoped data, you must add its reset to `resetCommunityState()`.**
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5c8e263a2a..892082d96c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -139,7 +139,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa
| 46001–46012 | KIND_WORKFLOW_* | Workflow execution events |
| 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat |
-`buzz-core` defines all 81 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).
+`buzz-core` defines each event kind as a `pub const u32` and exports the full registry as `ALL_KINDS: &[u32]` (127 kinds at the time of writing); `crates/buzz-core/src/kind.rs` is the source of truth for the current list. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).
Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `buzz-core/src/kind.rs` and imported by `buzz-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `buzz-core/src/kind.rs`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d83087fc26..171f260d3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,177 @@
# Changelog
+## v0.5.5
+
+### Desktop and shared changes
+
+- feat: paste composer text without formatting ([#4801](https://github.com/block/buzz/pull/4801)) ([`25a9cf1be6d245fbd7373cb1160dbc790baf5bd5`](https://github.com/block/buzz/commit/25a9cf1be6d245fbd7373cb1160dbc790baf5bd5))
+- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4808](https://github.com/block/buzz/pull/4808)) ([`79c52166cfe6b6d36bdc7686f943595c74e2f578`](https://github.com/block/buzz/commit/79c52166cfe6b6d36bdc7686f943595c74e2f578))
+- chore(release): release Buzz Desktop version 0.5.5 ([#4800](https://github.com/block/buzz/pull/4800)) ([`a0ed13de14ee64dd90c32335790f7d3b4e94330d`](https://github.com/block/buzz/commit/a0ed13de14ee64dd90c32335790f7d3b4e94330d))
+- fix: reauthenticate databricks model discovery ([#4008](https://github.com/block/buzz/pull/4008)) ([`4a2305170eef565bf1836e2859247e67c030f8af`](https://github.com/block/buzz/commit/4a2305170eef565bf1836e2859247e67c030f8af))
+- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4797](https://github.com/block/buzz/pull/4797)) ([`8faf09f9aedb4989e57c7b6c5bd1052a444a3370`](https://github.com/block/buzz/commit/8faf09f9aedb4989e57c7b6c5bd1052a444a3370))
+- feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues ([#4695](https://github.com/block/buzz/pull/4695)) ([`a1d78f2959b41c63f063ff818076d38c31071a47`](https://github.com/block/buzz/commit/a1d78f2959b41c63f063ff818076d38c31071a47))
+- fix(desktop): serialize tray channel actions for frontend ([#4762](https://github.com/block/buzz/pull/4762)) ([`4c665aeac366fca5097eaa1088fb87f3d248eac7`](https://github.com/block/buzz/commit/4c665aeac366fca5097eaa1088fb87f3d248eac7))
+- chore(release): release Buzz Desktop version 0.5.5 ([#4788](https://github.com/block/buzz/pull/4788)) ([`b948c54792c4933b4e003d2b227dc6e1f7c05fb4`](https://github.com/block/buzz/commit/b948c54792c4933b4e003d2b227dc6e1f7c05fb4))
+- feat(projects): support multiple repositories ([#4671](https://github.com/block/buzz/pull/4671)) ([`e30db7028f9f1dc7646b5814ed03b4c54a4d2a48`](https://github.com/block/buzz/commit/e30db7028f9f1dc7646b5814ed03b4c54a4d2a48))
+- fix(desktop): widen post-Enter timeouts in empty-edit-delete spec ([#4792](https://github.com/block/buzz/pull/4792)) ([`7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453`](https://github.com/block/buzz/commit/7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453))
+- fix(desktop): wait for terminal frame before splash ([#4781](https://github.com/block/buzz/pull/4781)) ([`65f7a100353b9a5302da2614f2d85edee1c136a2`](https://github.com/block/buzz/commit/65f7a100353b9a5302da2614f2d85edee1c136a2))
+- fix(desktop): integer-align custom reaction emoji ([#4779](https://github.com/block/buzz/pull/4779)) ([`8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a`](https://github.com/block/buzz/commit/8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a))
+- Polish Huddle voice controls ([#4694](https://github.com/block/buzz/pull/4694)) ([`ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d`](https://github.com/block/buzz/commit/ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d))
+- fix(local-archive): default both archive settings to enabled ([#4750](https://github.com/block/buzz/pull/4750)) ([`5179726737108a4a91076d262c30a53d4a7237e9`](https://github.com/block/buzz/commit/5179726737108a4a91076d262c30a53d4a7237e9))
+- fix(desktop): close reconnect gaps that previously required CMD+R ([#4737](https://github.com/block/buzz/pull/4737)) ([`e5efd047050f5e2a64fe6cd9e3faed1685b03f5c`](https://github.com/block/buzz/commit/e5efd047050f5e2a64fe6cd9e3faed1685b03f5c))
+- Dock Buzz Term within channel workspace ([#4724](https://github.com/block/buzz/pull/4724)) ([`cb4a73e17d0760eba6c3c01811da07e1d3a6b85e`](https://github.com/block/buzz/commit/cb4a73e17d0760eba6c3c01811da07e1d3a6b85e))
+- fix(agents): canonicalize stale persona harness pins ([#4631](https://github.com/block/buzz/pull/4631)) ([`0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8`](https://github.com/block/buzz/commit/0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8))
+- Refine community invite links ([#4734](https://github.com/block/buzz/pull/4734)) ([`e1287c92cc7ea9b52f10b80515b98cdd1c7f9a31`](https://github.com/block/buzz/commit/e1287c92cc7ea9b52f10b80515b98cdd1c7f9a31))
+- feat(desktop): persist sidebar observed-unread across webview reload ([#3976](https://github.com/block/buzz/pull/3976)) ([`0afeac8a7c173fd3ede8a22e27919e63161bf07c`](https://github.com/block/buzz/commit/0afeac8a7c173fd3ede8a22e27919e63161bf07c))
+- feat(desktop): surface config diff in restart-required badge ([#3637](https://github.com/block/buzz/pull/3637)) ([`f86dfc58838a272a5d0504ebf216a79b7288f027`](https://github.com/block/buzz/commit/f86dfc58838a272a5d0504ebf216a79b7288f027))
+- Polish sidebar unread hierarchy ([#4573](https://github.com/block/buzz/pull/4573)) ([`540b58920cef205b838da8be8442aae62bceaaa5`](https://github.com/block/buzz/commit/540b58920cef205b838da8be8442aae62bceaaa5))
+- fix(desktop): show cached display names on startup ([#3317](https://github.com/block/buzz/pull/3317)) ([`d0d4acd4fa02893ad2460b447d7e13da00506be3`](https://github.com/block/buzz/commit/d0d4acd4fa02893ad2460b447d7e13da00506be3))
+- Remove blur from Welcome composer guidance ([#4691](https://github.com/block/buzz/pull/4691)) ([`d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1`](https://github.com/block/buzz/commit/d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1))
+- Refine desktop timeline activity presentation ([#4582](https://github.com/block/buzz/pull/4582)) ([`a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a`](https://github.com/block/buzz/commit/a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a))
+- Defer desktop media uploads until send ([#4522](https://github.com/block/buzz/pull/4522)) ([`f18a9cb10688deaa3f618869170bfe9303c4be62`](https://github.com/block/buzz/commit/f18a9cb10688deaa3f618869170bfe9303c4be62))
+- fix(desktop): stop clipping focus ring on channel intro action cards (#2392) ([#4374](https://github.com/block/buzz/pull/4374)) ([`ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996`](https://github.com/block/buzz/commit/ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996))
+- Polish mobile inbox and media flows ([#4512](https://github.com/block/buzz/pull/4512)) ([`feccf4eabc23fdba94ce3537a194357ed17b197c`](https://github.com/block/buzz/commit/feccf4eabc23fdba94ce3537a194357ed17b197c))
+- feat: ship Buzz Term ([#4347](https://github.com/block/buzz/pull/4347)) ([`631b05c883f58e9533e9038b4669ebdfb1d9cf27`](https://github.com/block/buzz/commit/631b05c883f58e9533e9038b4669ebdfb1d9cf27))
+- feat(mobile): sync per-group channel sorting ([#4231](https://github.com/block/buzz/pull/4231)) ([`b42b093613edfb7138acb0961a0ad9218b39691a`](https://github.com/block/buzz/commit/b42b093613edfb7138acb0961a0ad9218b39691a))
+- feat(desktop): redesign the Huddle experience ([#4281](https://github.com/block/buzz/pull/4281)) ([`b29c8cdaa456307ecdd63e565de4beb14402128e`](https://github.com/block/buzz/commit/b29c8cdaa456307ecdd63e565de4beb14402128e))
+- feat(agents): model-tuning parity in global Agent Defaults editor ([#4578](https://github.com/block/buzz/pull/4578)) ([`985cdcc6eac33ccd77bc50c26e22c701d07eda4e`](https://github.com/block/buzz/commit/985cdcc6eac33ccd77bc50c26e22c701d07eda4e))
+- Polish Share Compute settings ([#3735](https://github.com/block/buzz/pull/3735)) ([`027a74a61c8643a1d1086d3e8307fad89d7735f7`](https://github.com/block/buzz/commit/027a74a61c8643a1d1086d3e8307fad89d7735f7))
+- fix(reactions): wrap long popover names ([#3834](https://github.com/block/buzz/pull/3834)) ([`79815978483ef0ab78f7159c0add3492da6457a1`](https://github.com/block/buzz/commit/79815978483ef0ab78f7159c0add3492da6457a1))
+- fix(desktop): clarify inherited agent parallelism ([#4010](https://github.com/block/buzz/pull/4010)) ([`d4a4570b9769743899d97480b3bf482860b51d9c`](https://github.com/block/buzz/commit/d4a4570b9769743899d97480b3bf482860b51d9c))
+- feat(desktop): make onboarding model defaults skippable ([#3968](https://github.com/block/buzz/pull/3968)) ([`5c98932c59ee5344e9e8c14525c51f3de16ad2c2`](https://github.com/block/buzz/commit/5c98932c59ee5344e9e8c14525c51f3de16ad2c2))
+
+### Other repository changes
+
+- fix(ci): make desktop cache test version agnostic ([#4791](https://github.com/block/buzz/pull/4791)) ([`383d9e1eafd569b44b9c835200dba69ef7cec9dc`](https://github.com/block/buzz/commit/383d9e1eafd569b44b9c835200dba69ef7cec9dc))
+- fix(mobile): stop oversized read-state retry loop ([#4595](https://github.com/block/buzz/pull/4595)) ([`7bee84da8267605ada939c4f911d90f1b0ff1a11`](https://github.com/block/buzz/commit/7bee84da8267605ada939c4f911d90f1b0ff1a11))
+- perf(relay): index channel-id lookups and skip trace-only reads ([#4647](https://github.com/block/buzz/pull/4647)) ([`bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c`](https://github.com/block/buzz/commit/bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c))
+- docs(acp): explain per-channel session model in base prompt ([#4729](https://github.com/block/buzz/pull/4729)) ([`56003ebf98c22367fb6357f295494e26efbd8ae6`](https://github.com/block/buzz/commit/56003ebf98c22367fb6357f295494e26efbd8ae6))
+- docs(nip-am): normative amendment — cache SHOULD/MUST + pricingIdentity + consumer cost guidance ([#4632](https://github.com/block/buzz/pull/4632)) ([`0542bc8b955756a62b4133aa70f84441d93616ee`](https://github.com/block/buzz/commit/0542bc8b955756a62b4133aa70f84441d93616ee))
+- feat(mobile): add channel scroll navigation ([#4239](https://github.com/block/buzz/pull/4239)) ([`d5da74e4e078a9551b9ce9e47e77cf9ed5840596`](https://github.com/block/buzz/commit/d5da74e4e078a9551b9ce9e47e77cf9ed5840596))
+- feat(mobile): bring channel menus to desktop parity ([#3940](https://github.com/block/buzz/pull/3940)) ([`ede8d22dd5b336f146e0a6d760fd9dff78a42613`](https://github.com/block/buzz/commit/ede8d22dd5b336f146e0a6d760fd9dff78a42613))
+- ci: add guarded desktop release cache prewarm ([#4575](https://github.com/block/buzz/pull/4575)) ([`e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0`](https://github.com/block/buzz/commit/e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0))
+- fix(mobile): recover stale relay sessions ([#4372](https://github.com/block/buzz/pull/4372)) ([`ce56e34411d2940e70a6c0de653ffae36d334701`](https://github.com/block/buzz/commit/ce56e34411d2940e70a6c0de653ffae36d334701))
+
+[Compare desktop-v0.5.4...desktop-v0.5.5](https://github.com/block/buzz/compare/desktop-v0.5.4...desktop-v0.5.5)
+
+## v0.5.4
+
+### Desktop and shared changes
+
+- fix: report agent usage per provider round, not once per turn ([#4545](https://github.com/block/buzz/pull/4545)) ([`09c86c56e52651c017743268fc8ce708bb83b265`](https://github.com/block/buzz/commit/09c86c56e52651c017743268fc8ce708bb83b265))
+- fix(desktop): harden Windows installs against Defender block and orphaned Node ([#4382](https://github.com/block/buzz/pull/4382)) ([`80315ac1a68024c40b61f3a062c9cb6bf7d4efb5`](https://github.com/block/buzz/commit/80315ac1a68024c40b61f3a062c9cb6bf7d4efb5))
+- feat(desktop): improve channel template discovery ([#4549](https://github.com/block/buzz/pull/4549)) ([`c1b88af8d71d1cf6aaca517e92ce9e918cd0e8bd`](https://github.com/block/buzz/commit/c1b88af8d71d1cf6aaca517e92ce9e918cd0e8bd))
+- fix(desktop): save key backups to authorized path ([#4022](https://github.com/block/buzz/pull/4022)) ([`01c80aa9b3eaa569361966877994438ad84a280a`](https://github.com/block/buzz/commit/01c80aa9b3eaa569361966877994438ad84a280a))
+- Add channel activity hover menu ([#3935](https://github.com/block/buzz/pull/3935)) ([`b0c6d6f744e63ac88a1738f0e995680c163e1d13`](https://github.com/block/buzz/commit/b0c6d6f744e63ac88a1738f0e995680c163e1d13))
+- feat(desktop): show saved Run on settings when editing an agent ([#4539](https://github.com/block/buzz/pull/4539)) ([`f865c0054b0a400657126c9321b4d4cb7d9cc746`](https://github.com/block/buzz/commit/f865c0054b0a400657126c9321b4d4cb7d9cc746))
+- fix(desktop): disambiguate provider API key labels and annotate mint key ([#4406](https://github.com/block/buzz/pull/4406)) ([`5e0efb0bb95182f588390b55cc5affa09114c87e`](https://github.com/block/buzz/commit/5e0efb0bb95182f588390b55cc5affa09114c87e))
+- fix(desktop): make OpenAI key re-enterable after first save in card mint dialog ([#4140](https://github.com/block/buzz/pull/4140)) ([`f810a2f49e213d25119f2aa75b5b577655119b74`](https://github.com/block/buzz/commit/f810a2f49e213d25119f2aa75b5b577655119b74))
+- fix(config-bridge): add harness-definition env tier and fix equal-value model override ([#3580](https://github.com/block/buzz/pull/3580)) ([`be95a8a986d02319b27e8fb57aefe59e33a1eb13`](https://github.com/block/buzz/commit/be95a8a986d02319b27e8fb57aefe59e33a1eb13))
+- fix(desktop): stop the create-agent provider config probe from erasing keystrokes ([#4411](https://github.com/block/buzz/pull/4411)) ([`2c0ac2467437b30953a95e00f419143488bcfcc7`](https://github.com/block/buzz/commit/2c0ac2467437b30953a95e00f419143488bcfcc7))
+- feat(acp): deliver system prompt via _meta.systemPrompt for claude-agent-acp ([#4395](https://github.com/block/buzz/pull/4395)) ([`7ff5fc31895efe6265a379d01637c8ee301872e5`](https://github.com/block/buzz/commit/7ff5fc31895efe6265a379d01637c8ee301872e5))
+- fix(security): bump nostr crates for RUSTSEC-2026-0225..0232 + default sprig image to published digest ([#4392](https://github.com/block/buzz/pull/4392)) ([`318fbf896ec335bc7bcb40edafde0b6ebca53428`](https://github.com/block/buzz/commit/318fbf896ec335bc7bcb40edafde0b6ebca53428))
+- fix(desktop): back/forward via keyboard chords, mouse X1/X2 buttons, and swipe gestures ([#3778](https://github.com/block/buzz/pull/3778)) ([`f86cfc7369d4471f8939ed98be6f597b0a4b0bb2`](https://github.com/block/buzz/commit/f86cfc7369d4471f8939ed98be6f597b0a4b0bb2))
+- feat(k8s): Kubernetes backend plugin + desktop deploy path ([#4289](https://github.com/block/buzz/pull/4289)) ([`6530b58a61d4602d0a371100fedf80c5998b1e34`](https://github.com/block/buzz/commit/6530b58a61d4602d0a371100fedf80c5998b1e34))
+- feat(projects): add buzz projects CLI commands (NIP-MP kind:30621) ([#4020](https://github.com/block/buzz/pull/4020)) ([`b7bb15122e8a2053b545dc2210afc167f6c7a626`](https://github.com/block/buzz/commit/b7bb15122e8a2053b545dc2210afc167f6c7a626))
+- fix(desktop): keep thread-open affordance in archived channels ([#4012](https://github.com/block/buzz/pull/4012)) ([`8e81afa431deecd172f1ad6aab6f022f31cd812c`](https://github.com/block/buzz/commit/8e81afa431deecd172f1ad6aab6f022f31cd812c))
+- fix(desktop): point Oh My Pi preset at omp.sh ([#3516](https://github.com/block/buzz/pull/3516)) ([`3ade48d5030a8f7dbb9d3693f171e5544dcd8df1`](https://github.com/block/buzz/commit/3ade48d5030a8f7dbb9d3693f171e5544dcd8df1))
+- fix(mesh): stop restarting a busy or loading shared-compute node ([#3909](https://github.com/block/buzz/pull/3909)) ([`fa1a5b1a797870724f5c7e7e26931861a60f22cb`](https://github.com/block/buzz/commit/fa1a5b1a797870724f5c7e7e26931861a60f22cb))
+- fix(desktop): preserve first huddle speech ([#3962](https://github.com/block/buzz/pull/3962)) ([`45314fc504113aec7c54ae6520cfe5e1562aae40`](https://github.com/block/buzz/commit/45314fc504113aec7c54ae6520cfe5e1562aae40))
+- feat(desktop): Agent Trading Cards — mintable agent-snapshot card PNGs with optional NIP-44 lock ([#3278](https://github.com/block/buzz/pull/3278)) ([`eb049ddf815d48195e1713afe039d28c950d7933`](https://github.com/block/buzz/commit/eb049ddf815d48195e1713afe039d28c950d7933))
+- feat(relay): accept kind:30621 multi-repo projects at ingest ([#3171](https://github.com/block/buzz/pull/3171)) ([`cb9701cd30fb344bf134585634a09007f3155bfb`](https://github.com/block/buzz/commit/cb9701cd30fb344bf134585634a09007f3155bfb))
+
+### Other repository changes
+
+- test(mobile): assert follow boundary semantics ([#4559](https://github.com/block/buzz/pull/4559)) ([`6de85fe31d781122756aecf954bae7d357a56b9a`](https://github.com/block/buzz/commit/6de85fe31d781122756aecf954bae7d357a56b9a))
+- docs(release): align desktop handoff instructions ([#3988](https://github.com/block/buzz/pull/3988)) ([`44fa1e8e3af30d561de981a211ff7a79bfa36493`](https://github.com/block/buzz/commit/44fa1e8e3af30d561de981a211ff7a79bfa36493))
+- Polish mobile composer and messaging UI ([#3918](https://github.com/block/buzz/pull/3918)) ([`857e63c4ddfb76f95ab40bb691e00544413f6b81`](https://github.com/block/buzz/commit/857e63c4ddfb76f95ab40bb691e00544413f6b81))
+- ci(linux): enable mesh-llm feature in Linux release and canary builds ([#4524](https://github.com/block/buzz/pull/4524)) ([`83a285f1b1a0be862d55781fad9c75ec8813886d`](https://github.com/block/buzz/commit/83a285f1b1a0be862d55781fad9c75ec8813886d))
+- fix(mobile): recover and pace live subscriptions ([#3053](https://github.com/block/buzz/pull/3053)) ([`a5dbdf5e61e4c512acd99c219c79c154ddb57295`](https://github.com/block/buzz/commit/a5dbdf5e61e4c512acd99c219c79c154ddb57295))
+- fix(git): allow deleting the default branch ([#4297](https://github.com/block/buzz/pull/4297)) ([`fc598f5f8d70728d11d0712b9fa8e3acc44ea4c3`](https://github.com/block/buzz/commit/fc598f5f8d70728d11d0712b9fa8e3acc44ea4c3))
+- docs: formal spec for remote agents and their management ([#3748](https://github.com/block/buzz/pull/3748)) ([`28ae6cd2174309529305724e455c7ca082f6fe4b`](https://github.com/block/buzz/commit/28ae6cd2174309529305724e455c7ca082f6fe4b))
+- fix(nip-oa): accept raw Nostr tag form in parse_json_array ([#4203](https://github.com/block/buzz/pull/4203)) ([`89bf03c05df795a3575b7abbe648be898ef13388`](https://github.com/block/buzz/commit/89bf03c05df795a3575b7abbe648be898ef13388))
+- perf(relay): serve relay-membership checks from the read replica ([#4124](https://github.com/block/buzz/pull/4124)) ([`ac4fa13b8e4d947071d57deb6918dcf12bf74961`](https://github.com/block/buzz/commit/ac4fa13b8e4d947071d57deb6918dcf12bf74961))
+- chore(deps): bump nostr-relay-pool for RUSTSEC-2026-0224 ([#4139](https://github.com/block/buzz/pull/4139)) ([`9d6726e5b387310975f5809473ce8372f6fde0dc`](https://github.com/block/buzz/commit/9d6726e5b387310975f5809473ce8372f6fde0dc))
+- docs(nostr): document #h requirement for live reaction subscriptions ([#3487](https://github.com/block/buzz/pull/3487)) ([`756dd7f65d6f2995e9188a0ffe54294057f8ef4f`](https://github.com/block/buzz/commit/756dd7f65d6f2995e9188a0ffe54294057f8ef4f))
+- docs(chart): fix ArgoCD example for native OCI sources (full artifact repoURL + path) ([#3426](https://github.com/block/buzz/pull/3426)) ([`36cf932ff0105a4cf574fc687deb4c1cb01bc0d1`](https://github.com/block/buzz/commit/36cf932ff0105a4cf574fc687deb4c1cb01bc0d1))
+- docs(readme): clarify which release asset to download per platform ([#3481](https://github.com/block/buzz/pull/3481)) ([`8d5afb606763fcaffd3af811be2106e41cc7347d`](https://github.com/block/buzz/commit/8d5afb606763fcaffd3af811be2106e41cc7347d))
+- fix(relay): allow open relays to set their NIP-11 workspace icon (kind:9033) ([#3998](https://github.com/block/buzz/pull/3998)) ([`5765fc74b77224f0207ddd4b41736a5ff18d333d`](https://github.com/block/buzz/commit/5765fc74b77224f0207ddd4b41736a5ff18d333d))
+- docs: note that addressable channel events scope by d, not h ([#4103](https://github.com/block/buzz/pull/4103)) ([`3d7712cc36e8da563cb1c121fc58bfc505d38496`](https://github.com/block/buzz/commit/3d7712cc36e8da563cb1c121fc58bfc505d38496))
+- docs: fix stale kind count, quick-start numbering, and empty Further Reading ([#2613](https://github.com/block/buzz/pull/2613)) ([`909a3b2c318b2ec477a3438a998a3b611f5b6d6a`](https://github.com/block/buzz/commit/909a3b2c318b2ec477a3438a998a3b611f5b6d6a))
+- docs: add one-click Railway deploy for a hosted relay ([#2733](https://github.com/block/buzz/pull/2733)) ([`19d57b0d46baa55814ac737041a36d0b405c9f64`](https://github.com/block/buzz/commit/19d57b0d46baa55814ac737041a36d0b405c9f64))
+- fix(buzz-acp): thread cache-read tokens into NIP-AM kind:44200 events ([#3999](https://github.com/block/buzz/pull/3999)) ([`b1b283cd4c7f926e12eeee8ae1f38c7471922b16`](https://github.com/block/buzz/commit/b1b283cd4c7f926e12eeee8ae1f38c7471922b16))
+- fix(release): preserve main in desktop PR body ([#3979](https://github.com/block/buzz/pull/3979)) ([`e5e5bac2a932b2b2e4eb6b559d5545a992c21b96`](https://github.com/block/buzz/commit/e5e5bac2a932b2b2e4eb6b559d5545a992c21b96))
+
+[Compare desktop-v0.5.3...desktop-v0.5.4](https://github.com/block/buzz/compare/desktop-v0.5.3...desktop-v0.5.4)
+
+## v0.5.3
+
+### Desktop and shared changes
+
+- Revert "chore(release): release Buzz Desktop version 0.5.3" ([#3960](https://github.com/block/buzz/pull/3960)) ([`bb34bc4d98fe4dabe847046103ac5e2859917ac5`](https://github.com/block/buzz/commit/bb34bc4d98fe4dabe847046103ac5e2859917ac5))
+- chore(release): release Buzz Desktop version 0.5.3 ([`d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131`](https://github.com/block/buzz/commit/d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131))
+- feat(desktop): import local Pocket voices ([#3259](https://github.com/block/buzz/pull/3259)) ([`c104eecfb38620de2c35c7e20a716f8658b5a6b1`](https://github.com/block/buzz/commit/c104eecfb38620de2c35c7e20a716f8658b5a6b1))
+- fix(desktop): open profiles from avatars ([#3751](https://github.com/block/buzz/pull/3751)) ([`39ce3dfc3cf2d12f0d6c64b4cd4293df86567663`](https://github.com/block/buzz/commit/39ce3dfc3cf2d12f0d6c64b4cd4293df86567663))
+- refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) ([#3910](https://github.com/block/buzz/pull/3910)) ([`61ba9dfaa00852925058d1a024322fa53663a5bc`](https://github.com/block/buzz/commit/61ba9dfaa00852925058d1a024322fa53663a5bc))
+- feat(desktop): auto-enable huddle transcription for agents ([#3180](https://github.com/block/buzz/pull/3180)) ([`4632c55041c5d423d572a6f6411bb7b279c26f67`](https://github.com/block/buzz/commit/4632c55041c5d423d572a6f6411bb7b279c26f67))
+- feat(agent): optional reply guard reminds a silent turn to publish ([#3763](https://github.com/block/buzz/pull/3763)) ([`081f805d5ea25841ab885c7b67a568618a34aa59`](https://github.com/block/buzz/commit/081f805d5ea25841ab885c7b67a568618a34aa59))
+- feat(desktop): upgrade Pocket TTS model ([#3266](https://github.com/block/buzz/pull/3266)) ([`d48b0e0eec4d2958f90a3cafa9d974450abe8501`](https://github.com/block/buzz/commit/d48b0e0eec4d2958f90a3cafa9d974450abe8501))
+- feat(desktop): delete a message by clearing its edit to empty ([#3813](https://github.com/block/buzz/pull/3813)) ([`d88313f369acfa17973029787ee4c0bbea07fa51`](https://github.com/block/buzz/commit/d88313f369acfa17973029787ee4c0bbea07fa51))
+- feat(relay): raise hosted community limit to five ([#3829](https://github.com/block/buzz/pull/3829)) ([`10d5a26414dc90dc89fd27de74b21e105d4fa622`](https://github.com/block/buzz/commit/10d5a26414dc90dc89fd27de74b21e105d4fa622))
+- feat(desktop): locally stored NIP-49 encrypted key backup ([#2937](https://github.com/block/buzz/pull/2937)) ([`468647a51f858b29d27eaf9fd07bf90294f99d39`](https://github.com/block/buzz/commit/468647a51f858b29d27eaf9fd07bf90294f99d39))
+- fix(catalog): update Amp tagline ([#3806](https://github.com/block/buzz/pull/3806)) ([`f3e5e812677f6f14bffe16a7aa02642d56faca4b`](https://github.com/block/buzz/commit/f3e5e812677f6f14bffe16a7aa02642d56faca4b))
+- fix(desktop): channel topic and membership metadata cleanup ([#3642](https://github.com/block/buzz/pull/3642)) ([`9e8fcfda099652926b921bca7fcc9bfecab0e140`](https://github.com/block/buzz/commit/9e8fcfda099652926b921bca7fcc9bfecab0e140))
+- fix(desktop): align data deletion labels ([#2230](https://github.com/block/buzz/pull/2230)) ([`ede26863345a518ec46edd6d7692e0281883491b`](https://github.com/block/buzz/commit/ede26863345a518ec46edd6d7692e0281883491b))
+- fix(desktop): allow linux-only media items as dead code off-linux ([#3811](https://github.com/block/buzz/pull/3811)) ([`36571f4adcfdcf3714a17bd968c58c78bcbdd9ef`](https://github.com/block/buzz/commit/36571f4adcfdcf3714a17bd968c58c78bcbdd9ef))
+- fix(desktop): report authenticated relay recovery ([#3812](https://github.com/block/buzz/pull/3812)) ([`74cd5712191bffd84ae688d59bb8b451c6eec1b0`](https://github.com/block/buzz/commit/74cd5712191bffd84ae688d59bb8b451c6eec1b0))
+- fix(desktop): don't gate hover affordances on the hover media query ([#3657](https://github.com/block/buzz/pull/3657)) ([`29dfe4821ed577489a1879fd2a9bfe2a621a52b3`](https://github.com/block/buzz/commit/29dfe4821ed577489a1879fd2a9bfe2a621a52b3))
+- feat(relay): gate kind 30178 team-catalog reads behind the shared tag ([#3358](https://github.com/block/buzz/pull/3358)) ([`114d40d9d37f05eff83ee90347ed93fb3da512c5`](https://github.com/block/buzz/commit/114d40d9d37f05eff83ee90347ed93fb3da512c5))
+- test(desktop): click visible thread collapse guide ([#3800](https://github.com/block/buzz/pull/3800)) ([`b9e4ed616f39b812bc964e79c7a40223c4e93832`](https://github.com/block/buzz/commit/b9e4ed616f39b812bc964e79c7a40223c4e93832))
+- feat(desktop): raise the install ceiling and make installs observable ([#3368](https://github.com/block/buzz/pull/3368)) ([`d40a33290e75791aa7ecf3ce7a252b66c2e35966`](https://github.com/block/buzz/commit/d40a33290e75791aa7ecf3ce7a252b66c2e35966))
+- Add Devin as a preset ACP harness ([#3225](https://github.com/block/buzz/pull/3225)) ([`1b3ff96a5764303998fa629ff852e81f1a88d7ad`](https://github.com/block/buzz/commit/1b3ff96a5764303998fa629ff852e81f1a88d7ad))
+- feat(desktop): improve agent activity header ui ([#3321](https://github.com/block/buzz/pull/3321)) ([`4d47aa83455a9fd024121a596154cd311dca1d76`](https://github.com/block/buzz/commit/4d47aa83455a9fd024121a596154cd311dca1d76))
+- perf(presence): reduce heartbeat frequency ([#3783](https://github.com/block/buzz/pull/3783)) ([`bf139e8d0bdba10df9a5adbf16843140e0a78a59`](https://github.com/block/buzz/commit/bf139e8d0bdba10df9a5adbf16843140e0a78a59))
+- Tighten continuation message rows ([#3724](https://github.com/block/buzz/pull/3724)) ([`6e419b9f1c873549a7b40996970e0da7352adafb`](https://github.com/block/buzz/commit/6e419b9f1c873549a7b40996970e0da7352adafb))
+- Fix video reviews in thread replies ([#3719](https://github.com/block/buzz/pull/3719)) ([`f48f3f055fdd6030d3832f615f8c0d8e5a81261a`](https://github.com/block/buzz/commit/f48f3f055fdd6030d3832f615f8c0d8e5a81261a))
+- Make relay reconnect backoff authoritative ([#3774](https://github.com/block/buzz/pull/3774)) ([`cca8839034eb571a7ce943c3ace7f85a82330898`](https://github.com/block/buzz/commit/cca8839034eb571a7ce943c3ace7f85a82330898))
+- feat(desktop): add password-protected backups in settings ([#3701](https://github.com/block/buzz/pull/3701)) ([`bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c`](https://github.com/block/buzz/commit/bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c))
+- fix(desktop): reuse profiles when joining communities ([#2155](https://github.com/block/buzz/pull/2155)) ([`f44b5a2477f3979ae66e49153b11be36538cf859`](https://github.com/block/buzz/commit/f44b5a2477f3979ae66e49153b11be36538cf859))
+- fix(catalog): update Amp description ([#3758](https://github.com/block/buzz/pull/3758)) ([`61b96c9828d1dd54106b570d87a54edbc92bb9c4`](https://github.com/block/buzz/commit/61b96c9828d1dd54106b570d87a54edbc92bb9c4))
+- feat(catalog): resolve publisher display name in catalog detail pane ([#3640](https://github.com/block/buzz/pull/3640)) ([`02be413b823c356587e6e9f4d07f6cb06bb41c3c`](https://github.com/block/buzz/commit/02be413b823c356587e6e9f4d07f6cb06bb41c3c))
+- feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) ([#3741](https://github.com/block/buzz/pull/3741)) ([`4933672eb4589e7208b312829ebddcd10dfa9dd3`](https://github.com/block/buzz/commit/4933672eb4589e7208b312829ebddcd10dfa9dd3))
+- Refine agent sharing dialog ([#3699](https://github.com/block/buzz/pull/3699)) ([`9a386a0defbf2b355ee17646c7c11817a535b85f`](https://github.com/block/buzz/commit/9a386a0defbf2b355ee17646c7c11817a535b85f))
+- desktop: enable getUserMedia in the Linux WebKitGTK webview ([#3607](https://github.com/block/buzz/pull/3607)) ([`c9aa55505c544c608ff71648bbfd21b235637f19`](https://github.com/block/buzz/commit/c9aa55505c544c608ff71648bbfd21b235637f19))
+- fix: align responsive agent views ([#3688](https://github.com/block/buzz/pull/3688)) ([`73589408db6fd96b87ac570935d414ecc4120f53`](https://github.com/block/buzz/commit/73589408db6fd96b87ac570935d414ecc4120f53))
+- Add macOS agent menu-bar menu ([#3565](https://github.com/block/buzz/pull/3565)) ([`d0a24bcb5210326da4c0b1e749ee3935621b329c`](https://github.com/block/buzz/commit/d0a24bcb5210326da4c0b1e749ee3935621b329c))
+- Fix pending message feedback ([#3543](https://github.com/block/buzz/pull/3543)) ([`4672ee55c4e4a7916c31bfeae5df2fb4384bed10`](https://github.com/block/buzz/commit/4672ee55c4e4a7916c31bfeae5df2fb4384bed10))
+- fix(desktop): remove remaining Projects panel fills ([#3742](https://github.com/block/buzz/pull/3742)) ([`c55e421a0629c74b9ffd96ee3ccde36f006196ed`](https://github.com/block/buzz/commit/c55e421a0629c74b9ffd96ee3ccde36f006196ed))
+- desktop: restore direct community member adds ([#3634](https://github.com/block/buzz/pull/3634)) ([`310df2ec33fbb075edf226ba18bf9a96d90ba81b`](https://github.com/block/buzz/commit/310df2ec33fbb075edf226ba18bf9a96d90ba81b))
+- fix(desktop): explain open agent access ([#2561](https://github.com/block/buzz/pull/2561)) ([`7fb008f9347b933b9a1da20a7afb070912b430e8`](https://github.com/block/buzz/commit/7fb008f9347b933b9a1da20a7afb070912b430e8))
+- fix(desktop): remove Projects overview card fills ([#3416](https://github.com/block/buzz/pull/3416)) ([`3b8567a05d4c40e667d061666feb7aa7bc38212d`](https://github.com/block/buzz/commit/3b8567a05d4c40e667d061666feb7aa7bc38212d))
+- fix(git): channel binding tooling + author remediation for unbound repos ([#3626](https://github.com/block/buzz/pull/3626)) ([`788b3c002bd2509455444f57f8a03a054b4b496a`](https://github.com/block/buzz/commit/788b3c002bd2509455444f57f8a03a054b4b496a))
+- feat: configure S3 URL addressing style ([#3400](https://github.com/block/buzz/pull/3400)) ([`7012d86d52fd188b27c7beedeaa132d9c1f61fa8`](https://github.com/block/buzz/commit/7012d86d52fd188b27c7beedeaa132d9c1f61fa8))
+- feat: add first-class OpenRouter provider support ([#1975](https://github.com/block/buzz/pull/1975)) ([`ab55fee81896d2b03edf5d2ca5012b715be2b93d`](https://github.com/block/buzz/commit/ab55fee81896d2b03edf5d2ca5012b715be2b93d))
+- feat(agent,acp): wire provider total_tokens through NIP-AM publish chain ([#3593](https://github.com/block/buzz/pull/3593)) ([`f95fdc1a102e17c6718a44323d9a2feaed702db7`](https://github.com/block/buzz/commit/f95fdc1a102e17c6718a44323d9a2feaed702db7))
+
+### Other repository changes
+
+- fix(release): require exact-head approval for desktop tags ([#3973](https://github.com/block/buzz/pull/3973)) ([`54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a`](https://github.com/block/buzz/commit/54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a))
+- fix(release): make desktop tagging squash-safe ([#3965](https://github.com/block/buzz/pull/3965)) ([`db7e84d4f815127236b9cb080c5d374f48eaac09`](https://github.com/block/buzz/commit/db7e84d4f815127236b9cb080c5d374f48eaac09))
+- docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS ([#2864](https://github.com/block/buzz/pull/2864)) ([`209536ade6c5ebf7fa82671d7ca0b74f599a40cc`](https://github.com/block/buzz/commit/209536ade6c5ebf7fa82671d7ca0b74f599a40cc))
+- fix(release): make immutable desktop release operable ([#3943](https://github.com/block/buzz/pull/3943)) ([`052174a148f9f6bcbb2b5a1d20ce0317645e49f8`](https://github.com/block/buzz/commit/052174a148f9f6bcbb2b5a1d20ce0317645e49f8))
+- docs: add VISION_REMOTE_AGENTS.md ([#3924](https://github.com/block/buzz/pull/3924)) ([`689617af7ad420c3266d5d2eb437757371327089`](https://github.com/block/buzz/commit/689617af7ad420c3266d5d2eb437757371327089))
+- fix(relay): align NIP-11 max_limit with REQ ceiling ([#3635](https://github.com/block/buzz/pull/3635)) ([`23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9`](https://github.com/block/buzz/commit/23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9))
+- fix(db): isolate usage metrics advisory-lock test on scratch DB ([#3670](https://github.com/block/buzz/pull/3670)) ([`dba97eecd9d8659c9c816cd6666fa6d687b6bca1`](https://github.com/block/buzz/commit/dba97eecd9d8659c9c816cd6666fa6d687b6bca1))
+- feat(release): make desktop releases immutable ([#3568](https://github.com/block/buzz/pull/3568)) ([`1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a`](https://github.com/block/buzz/commit/1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a))
+- Render mobile agent mention chips ([#3702](https://github.com/block/buzz/pull/3702)) ([`06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a`](https://github.com/block/buzz/commit/06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a))
+- fix(acp): preserve truncated thread context ([#3340](https://github.com/block/buzz/pull/3340)) ([`53771c8f5439f9c5c26876f0229bfcfe5da9b170`](https://github.com/block/buzz/commit/53771c8f5439f9c5c26876f0229bfcfe5da9b170))
+- docs(nips): specify kind:30621 multi-repo projects (NIP-MP) ([#3163](https://github.com/block/buzz/pull/3163)) ([`33bf7caa6ea474ccde2932c1ed05a90d7345c6e0`](https://github.com/block/buzz/commit/33bf7caa6ea474ccde2932c1ed05a90d7345c6e0))
+- feat(mobile): desktop-parity emoji and thread experience ([#3485](https://github.com/block/buzz/pull/3485)) ([`85edc0572a8540dedfa6562d40f0f875af0b5f61`](https://github.com/block/buzz/commit/85edc0572a8540dedfa6562d40f0f875af0b5f61))
+- fix(cli): resolve agents from owner records ([#3178](https://github.com/block/buzz/pull/3178)) ([`262f2392e3b7e09c78d582fb384672034d8551d5`](https://github.com/block/buzz/commit/262f2392e3b7e09c78d582fb384672034d8551d5))
+- feat(replica): portable heartbeat-token fence with snapshot-local reader routing ([#3268](https://github.com/block/buzz/pull/3268)) ([`63496cc1d4c6f1b7c613801bdcc694169dcf391a`](https://github.com/block/buzz/commit/63496cc1d4c6f1b7c613801bdcc694169dcf391a))
+
+[Compare v0.5.2...desktop-v0.5.3](https://github.com/block/buzz/compare/v0.5.2...desktop-v0.5.3)
+
## v0.5.2
- feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b))
diff --git a/Cargo.lock b/Cargo.lock
index 8574274303..73ecb249d4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -52,6 +52,7 @@ dependencies = [
"cfg-if 1.0.4",
"getrandom 0.3.4",
"once_cell",
+ "serde",
"version_check",
"zerocopy",
]
@@ -144,7 +145,7 @@ checksum = "5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7"
dependencies = [
"arrayvec",
"aws-lc-rs",
- "base64",
+ "base64 0.22.1",
"byteorder",
"minicbor",
"rustls-pki-types",
@@ -409,13 +410,23 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "atomic-write-file"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d"
+dependencies = [
+ "nix 0.30.1",
+ "rand 0.9.4",
+]
+
[[package]]
name = "attohttpc"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"
dependencies = [
- "base64",
+ "base64 0.22.1",
"http",
"log",
"rustls",
@@ -486,7 +497,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"axum-macros",
- "base64",
+ "base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -562,6 +573,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -580,6 +597,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
+[[package]]
+name = "beef"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
+
[[package]]
name = "bip39"
version = "2.2.2"
@@ -778,7 +801,7 @@ name = "buzz-acp"
version = "0.1.0"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"buzz-core",
"buzz-persona",
"buzz-sdk",
@@ -838,7 +861,7 @@ dependencies = [
"arc-swap",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"getrandom 0.4.3",
"hex",
"nix 0.31.3",
@@ -892,12 +915,32 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "buzz-backend-kubernetes"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "hex",
+ "http",
+ "http-body-util",
+ "k8s-openapi",
+ "kube",
+ "nostr",
+ "rand 0.10.1",
+ "rustls",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "tokio",
+ "tower",
+]
+
[[package]]
name = "buzz-cli"
version = "0.1.0"
dependencies = [
"axum",
- "base64",
+ "base64 0.22.1",
"buzz-core",
"buzz-persona",
"buzz-sdk",
@@ -938,7 +981,7 @@ dependencies = [
name = "buzz-core"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"chrono",
"hex",
"hmac 0.13.0",
@@ -980,7 +1023,7 @@ dependencies = [
name = "buzz-dev-mcp"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"buzz-cli",
"buzz-core",
"git-credential-nostr",
@@ -1107,7 +1150,7 @@ dependencies = [
"appattest",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"byteorder",
"chrono",
"getrandom 0.4.3",
@@ -1142,7 +1185,7 @@ dependencies = [
"async-compression",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"buzz-audit",
"buzz-auth",
"buzz-conformance",
@@ -1153,11 +1196,13 @@ dependencies = [
"buzz-relay-mesh",
"buzz-sdk",
"buzz-search",
+ "buzz-test-client",
"buzz-workflow",
"bytes",
"chrono",
"dashmap",
"deadpool-redis",
+ "ed25519-dalek",
"flate2",
"futures",
"futures-util",
@@ -1252,7 +1297,7 @@ name = "buzz-test-client"
version = "0.1.0"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"buzz-core",
"buzz-media",
"buzz-sdk",
@@ -1278,6 +1323,25 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "buzz-voice"
+version = "0.1.0"
+dependencies = [
+ "atomic-write-file",
+ "hex",
+ "ort",
+ "ort-sys",
+ "rand 0.10.1",
+ "sentencepiece-model",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "sherpa-onnx",
+ "symphonia",
+ "tempfile",
+ "tokenizers",
+]
+
[[package]]
name = "buzz-workflow"
version = "0.1.0"
@@ -1345,6 +1409,26 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "bzip2"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
+dependencies = [
+ "bzip2-sys",
+ "libc",
+]
+
+[[package]]
+name = "bzip2-sys"
+version = "0.1.13+1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
[[package]]
name = "castaway"
version = "0.2.4"
@@ -1593,6 +1677,7 @@ dependencies = [
"itoa",
"rustversion",
"ryu",
+ "serde",
"static_assertions",
]
@@ -2153,6 +2238,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "dary_heap"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "dashmap"
version = "6.2.1"
@@ -2642,6 +2736,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "esaxx-rs"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
+
[[package]]
name = "etcetera"
version = "0.11.0"
@@ -2699,6 +2799,12 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "extended"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
+
[[package]]
name = "fancy-regex"
version = "0.11.0"
@@ -2709,6 +2815,17 @@ dependencies = [
"regex",
]
+[[package]]
+name = "fancy-regex"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
+dependencies = [
+ "bit-set 0.8.0",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "fast-srgb8"
version = "1.0.0"
@@ -3022,8 +3139,8 @@ dependencies = [
"libc",
"log",
"rustversion",
- "windows-link 0.1.3",
- "windows-result 0.3.4",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
]
[[package]]
@@ -3100,7 +3217,7 @@ dependencies = [
name = "git-credential-nostr"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"nostr",
"serde_json",
"zeroize",
@@ -3110,7 +3227,7 @@ dependencies = [
name = "git-sign-nostr"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"chrono",
"hex",
"libc",
@@ -3285,7 +3402,7 @@ version = "1.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bon",
"bytes",
"futures",
@@ -3548,6 +3665,7 @@ dependencies = [
"http",
"hyper",
"hyper-util",
+ "log",
"rustls",
"rustls-native-certs",
"tokio",
@@ -3591,7 +3709,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
@@ -3622,7 +3740,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.61.2",
+ "windows-core 0.62.2",
]
[[package]]
@@ -4208,6 +4326,31 @@ dependencies = [
"ucd-trie",
]
+[[package]]
+name = "jsonpath-rust"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b"
+dependencies = [
+ "pest",
+ "pest_derive",
+ "regex",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "k8s-openapi"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06d9e5e61dd037cdc51da0d7e2b2be10f497478ea7e120d85dad632adb99882b"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "kasuari"
version = "0.4.12"
@@ -4253,6 +4396,70 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a"
+[[package]]
+name = "kube"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48e7bb0b6a46502cc20e4575b6ff401af45cfea150b34ba272a3410b78aa014e"
+dependencies = [
+ "k8s-openapi",
+ "kube-client",
+ "kube-core",
+]
+
+[[package]]
+name = "kube-client"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4987d57a184d2b5294fdad3d7fc7f278899469d21a4da39a8f6ca16426567a36"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "chrono",
+ "either",
+ "futures",
+ "home",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-timeout",
+ "hyper-util",
+ "jsonpath-rust",
+ "k8s-openapi",
+ "kube-core",
+ "pem",
+ "rustls",
+ "secrecy",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tracing",
+]
+
+[[package]]
+name = "kube-core"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "914bbb770e7bb721a06e3538c0edd2babed46447d128f7c21caa68747060ee73"
+dependencies = [
+ "chrono",
+ "derive_more",
+ "form_urlencoded",
+ "http",
+ "k8s-openapi",
+ "serde",
+ "serde-value",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "lab"
version = "0.11.0"
@@ -4375,6 +4582,39 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+[[package]]
+name = "logos"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458"
+dependencies = [
+ "logos-derive",
+]
+
+[[package]]
+name = "logos-codegen"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f"
+dependencies = [
+ "beef",
+ "fnv",
+ "lazy_static",
+ "proc-macro2",
+ "quote",
+ "regex-syntax",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "logos-derive"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d"
+dependencies = [
+ "logos-codegen",
+]
+
[[package]]
name = "loom"
version = "0.7.2"
@@ -4434,6 +4674,22 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "macro_rules_attribute"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c"
+dependencies = [
+ "macro_rules_attribute-proc_macro",
+ "pastey",
+]
+
+[[package]]
+name = "macro_rules_attribute-proc_macro"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c"
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -4449,6 +4705,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+[[package]]
+name = "matrixmultiply"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
[[package]]
name = "maybe-async"
version = "0.2.11"
@@ -4545,7 +4811,7 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05
dependencies = [
"anyhow",
"async-trait",
- "base64",
+ "base64 0.22.1",
"bytes",
"crypto_box",
"ed25519-dalek",
@@ -4558,7 +4824,7 @@ dependencies = [
"mesh-llm-types",
"model-artifact",
"nostr-sdk",
- "prost",
+ "prost 0.14.3",
"rand 0.10.1",
"rustls",
"serde",
@@ -4647,7 +4913,7 @@ dependencies = [
"argon2",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"bytes",
"chacha20poly1305",
"chrono",
@@ -4696,7 +4962,7 @@ dependencies = [
"opentelemetry 0.31.0",
"opentelemetry-otlp 0.31.1",
"opentelemetry_sdk 0.31.0",
- "prost",
+ "prost 0.14.3",
"rand 0.10.1",
"regex-lite",
"reqwest 0.12.28",
@@ -4737,7 +5003,7 @@ version = "0.74.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"argon2",
- "base64",
+ "base64 0.22.1",
"chacha20poly1305",
"chrono",
"crypto_box",
@@ -4785,8 +5051,8 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05
dependencies = [
"anyhow",
"async-trait",
- "prost",
- "prost-build",
+ "prost 0.14.3",
+ "prost-build 0.14.3",
"protoc-bin-vendored",
"rmcp",
"schemars",
@@ -4822,7 +5088,7 @@ dependencies = [
"anyhow",
"hex",
"iroh",
- "prost",
+ "prost 0.14.3",
"serde_json",
"sha2 0.10.9",
]
@@ -4953,7 +5219,7 @@ version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
dependencies = [
- "base64",
+ "base64 0.22.1",
"evmap",
"http-body-util",
"hyper",
@@ -4991,6 +5257,28 @@ dependencies = [
"sketches-ddsketch",
]
+[[package]]
+name = "miette"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+dependencies = [
+ "cfg-if 1.0.4",
+ "miette-derive",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "miette-derive"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -5136,6 +5424,28 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "monostate"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67"
+dependencies = [
+ "monostate-impl",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "monostate-impl"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "more-asserts"
version = "0.3.1"
@@ -5242,6 +5552,21 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "ndarray"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
+]
+
[[package]]
name = "ndk-context"
version = "0.1.1"
@@ -5388,6 +5713,18 @@ dependencies = [
"memoffset",
]
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if 1.0.4",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nix"
version = "0.31.3"
@@ -5478,7 +5815,7 @@ version = "0.44.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bech32",
"bip39",
"bitcoin_hashes",
@@ -5949,7 +6286,7 @@ dependencies = [
"opentelemetry-http",
"opentelemetry-proto 0.31.0",
"opentelemetry_sdk 0.31.0",
- "prost",
+ "prost 0.14.3",
"reqwest 0.12.28",
"thiserror 2.0.18",
]
@@ -5964,7 +6301,7 @@ dependencies = [
"opentelemetry 0.32.0",
"opentelemetry-proto 0.32.0",
"opentelemetry_sdk 0.32.1",
- "prost",
+ "prost 0.14.3",
"thiserror 2.0.18",
"tokio",
"tonic",
@@ -5977,11 +6314,11 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
dependencies = [
- "base64",
+ "base64 0.22.1",
"const-hex",
"opentelemetry 0.31.0",
"opentelemetry_sdk 0.31.0",
- "prost",
+ "prost 0.14.3",
"serde",
"serde_json",
"tonic",
@@ -5996,7 +6333,7 @@ checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
dependencies = [
"opentelemetry 0.32.0",
"opentelemetry_sdk 0.32.1",
- "prost",
+ "prost 0.14.3",
"tonic",
"tonic-prost",
]
@@ -6040,6 +6377,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "ordered-float"
+version = "2.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "ordered-float"
version = "4.6.0"
@@ -6078,6 +6424,24 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "ort"
+version = "2.0.0-rc.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
+dependencies = [
+ "ndarray",
+ "ort-sys",
+ "smallvec",
+ "tracing",
+]
+
+[[package]]
+name = "ort-sys"
+version = "2.0.0-rc.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
+
[[package]]
name = "os_str_bytes"
version = "6.6.1"
@@ -6202,6 +6566,16 @@ dependencies = [
"hmac 0.12.1",
]
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64 0.22.1",
+ "serde_core",
+]
+
[[package]]
name = "pem-rfc7468"
version = "1.0.0"
@@ -6259,6 +6633,16 @@ dependencies = [
"pest",
]
+[[package]]
+name = "petgraph"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
+dependencies = [
+ "fixedbitset 0.5.7",
+ "indexmap",
+]
+
[[package]]
name = "petgraph"
version = "0.8.3"
@@ -6391,7 +6775,7 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
- "base64",
+ "base64 0.22.1",
"indexmap",
"quick-xml 0.39.4",
"serde",
@@ -6457,13 +6841,22 @@ dependencies = [
"serde",
]
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
[[package]]
name = "portmapper"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"derive_more",
"hyper-util",
@@ -6633,6 +7026,16 @@ dependencies = [
"unarray",
]
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive 0.13.5",
+]
+
[[package]]
name = "prost"
version = "0.14.3"
@@ -6640,7 +7043,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
- "prost-derive",
+ "prost-derive 0.14.3",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
+dependencies = [
+ "heck",
+ "itertools",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph 0.7.1",
+ "prettyplease",
+ "prost 0.13.5",
+ "prost-types 0.13.5",
+ "regex",
+ "syn 2.0.117",
+ "tempfile",
]
[[package]]
@@ -6653,15 +7076,28 @@ dependencies = [
"itertools",
"log",
"multimap",
- "petgraph",
+ "petgraph 0.8.3",
"prettyplease",
- "prost",
- "prost-types",
+ "prost 0.14.3",
+ "prost-types 0.14.3",
"regex",
"syn 2.0.117",
"tempfile",
]
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "prost-derive"
version = "0.14.3"
@@ -6675,13 +7111,35 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "prost-reflect"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2"
+dependencies = [
+ "logos",
+ "miette",
+ "once_cell",
+ "prost 0.13.5",
+ "prost-types 0.13.5",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
+dependencies = [
+ "prost 0.13.5",
+]
+
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
- "prost",
+ "prost 0.14.3",
]
[[package]]
@@ -6748,6 +7206,33 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
+[[package]]
+name = "protox"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a"
+dependencies = [
+ "bytes",
+ "miette",
+ "prost 0.13.5",
+ "prost-reflect",
+ "prost-types 0.13.5",
+ "protox-parse",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "protox-parse"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde"
+dependencies = [
+ "logos",
+ "miette",
+ "prost-types 0.13.5",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
@@ -7053,7 +7538,7 @@ dependencies = [
"thiserror 2.0.18",
"unicode-segmentation",
"unicode-truncate",
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -7116,7 +7601,7 @@ dependencies = [
"strum",
"time",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -7128,6 +7613,43 @@ dependencies = [
"bitflags 2.13.0",
]
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-cond"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
+dependencies = [
+ "either",
+ "itertools",
+ "rayon",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
[[package]]
name = "redb"
version = "3.1.3"
@@ -7249,7 +7771,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
@@ -7297,7 +7819,7 @@ version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
@@ -7387,7 +7909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59"
dependencies = [
"async-trait",
- "base64",
+ "base64 0.22.1",
"bytes",
"chrono",
"futures",
@@ -7455,7 +7977,7 @@ dependencies = [
"async-trait",
"aws-creds",
"aws-region",
- "base64",
+ "base64 0.22.1",
"bytes",
"cfg-if 1.0.4",
"futures-util",
@@ -7779,6 +8301,15 @@ dependencies = [
"cc",
]
+[[package]]
+name = "secrecy"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
+dependencies = [
+ "zeroize",
+]
+
[[package]]
name = "secret-service"
version = "4.0.0"
@@ -7856,6 +8387,18 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+[[package]]
+name = "sentencepiece-model"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec"
+dependencies = [
+ "miette",
+ "prost 0.13.5",
+ "prost-build 0.13.5",
+ "protox",
+]
+
[[package]]
name = "serde"
version = "1.0.228"
@@ -7866,6 +8409,16 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde-value"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
+dependencies = [
+ "ordered-float 2.10.1",
+ "serde",
+]
+
[[package]]
name = "serde_bytes"
version = "0.11.19"
@@ -8066,6 +8619,28 @@ dependencies = [
"os_str_bytes",
]
+[[package]]
+name = "sherpa-onnx"
+version = "1.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b142d3f255cb4e4b7808ea25869db6f5714e0a3550da355234483b4db552055"
+dependencies = [
+ "serde",
+ "serde_json",
+ "sherpa-onnx-sys",
+]
+
+[[package]]
+name = "sherpa-onnx-sys"
+version = "1.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffc951af03dc0653c0622158ca8a585a6f2bc43b7b06048cf0e5b5020005c227"
+dependencies = [
+ "bzip2",
+ "tar",
+ "ureq",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -8201,8 +8776,8 @@ name = "skippy-protocol"
version = "0.74.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
- "prost",
- "prost-build",
+ "prost 0.14.3",
+ "prost-build 0.14.3",
"protoc-bin-vendored",
"serde",
]
@@ -8230,7 +8805,7 @@ dependencies = [
"anyhow",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"blake3",
"clap",
"futures-util",
@@ -8337,6 +8912,18 @@ dependencies = [
"der",
]
+[[package]]
+name = "spm_precompiled"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
+dependencies = [
+ "base64 0.13.1",
+ "nom",
+ "serde",
+ "unicode-segmentation",
+]
+
[[package]]
name = "sprig"
version = "0.1.0"
@@ -8365,7 +8952,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"cfg-if 1.0.4",
"chrono",
@@ -8471,7 +9058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
dependencies = [
"atoi",
- "base64",
+ "base64 0.22.1",
"bitflags 2.13.0",
"byteorder",
"chrono",
@@ -8612,6 +9199,164 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
+[[package]]
+name = "symphonia"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
+dependencies = [
+ "lazy_static",
+ "symphonia-bundle-flac",
+ "symphonia-bundle-mp3",
+ "symphonia-codec-aac",
+ "symphonia-codec-alac",
+ "symphonia-codec-pcm",
+ "symphonia-codec-vorbis",
+ "symphonia-core",
+ "symphonia-format-isomp4",
+ "symphonia-format-ogg",
+ "symphonia-format-riff",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-bundle-flac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-bundle-mp3"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-codec-aac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-alac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
+dependencies = [
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-pcm"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
+dependencies = [
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-vorbis"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-core"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
+dependencies = [
+ "arrayvec",
+ "bitflags 1.3.2",
+ "bytemuck",
+ "lazy_static",
+ "log",
+]
+
+[[package]]
+name = "symphonia-format-isomp4"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
+dependencies = [
+ "encoding_rs",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-format-ogg"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-format-riff"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
+dependencies = [
+ "extended",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-metadata"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
+dependencies = [
+ "encoding_rs",
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-utils-xiph"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
+dependencies = [
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
[[package]]
name = "syn"
version = "1.0.109"
@@ -8709,7 +9454,7 @@ version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432"
dependencies = [
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -8783,9 +9528,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"bitflags 2.13.0",
- "fancy-regex",
+ "fancy-regex 0.11.0",
"filedescriptor",
"finl_unicode",
"fixedbitset 0.4.2",
@@ -8935,6 +9680,39 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+[[package]]
+name = "tokenizers"
+version = "0.22.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223"
+dependencies = [
+ "ahash",
+ "aho-corasick",
+ "compact_str 0.9.1",
+ "dary_heap",
+ "derive_builder",
+ "esaxx-rs",
+ "fancy-regex 0.14.0",
+ "getrandom 0.3.4",
+ "itertools",
+ "log",
+ "macro_rules_attribute",
+ "monostate",
+ "paste",
+ "rand 0.9.4",
+ "rayon",
+ "rayon-cond",
+ "regex",
+ "regex-syntax",
+ "serde",
+ "serde_json",
+ "spm_precompiled",
+ "thiserror 2.0.18",
+ "unicode-normalization-alignments",
+ "unicode-segmentation",
+ "unicode_categories",
+]
+
[[package]]
name = "tokio"
version = "1.52.3"
@@ -9070,7 +9848,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-core",
"futures-sink",
@@ -9171,7 +9949,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"bytes",
"h2",
"http",
@@ -9200,7 +9978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
- "prost",
+ "prost 0.14.3",
"tonic",
]
@@ -9210,8 +9988,8 @@ version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8"
dependencies = [
- "prost",
- "prost-types",
+ "prost 0.14.3",
+ "prost-types 0.14.3",
"tonic",
]
@@ -9241,6 +10019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"async-compression",
+ "base64 0.22.1",
"bitflags 2.13.0",
"bytes",
"futures-core",
@@ -9500,6 +10279,15 @@ dependencies = [
"tinyvec",
]
+[[package]]
+name = "unicode-normalization-alignments"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de"
+dependencies = [
+ "smallvec",
+]
+
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -9520,9 +10308,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.2.2",
]
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -9535,6 +10329,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -9557,6 +10357,22 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+[[package]]
+name = "ureq"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
+dependencies = [
+ "base64 0.22.1",
+ "flate2",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-pki-types",
+ "url",
+ "webpki-roots 0.26.11",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -10480,8 +11296,8 @@ dependencies = [
"log",
"serde",
"thiserror 2.0.18",
- "windows 0.61.3",
- "windows-core 0.61.2",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
]
[[package]]
@@ -10548,7 +11364,7 @@ checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8"
dependencies = [
"anyhow",
"async-trait",
- "base64",
+ "base64 0.22.1",
"bytes",
"clap",
"crc32fast",
@@ -10585,7 +11401,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee"
dependencies = [
"async-trait",
- "base64",
+ "base64 0.22.1",
"blake3",
"bytemuck",
"bytes",
diff --git a/Cargo.toml b/Cargo.toml
index 3ac7ee4cce..cc1dd0f9df 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,6 +26,8 @@ members = [
"crates/buzz-pair-relay",
"crates/buzz-relay-mesh",
"crates/buzz-dev-mcp",
+ "crates/buzz-voice",
+ "crates/buzz-backend-kubernetes",
"examples/countdown-bot",
]
exclude = ["desktop/src-tauri"]
@@ -57,6 +59,13 @@ sqlx = { version = "0.9", features = [
redis = { version = "1.0", features = ["tokio-comp", "connection-manager", "tokio-rustls-comp"] }
deadpool-redis = { version = "0.23", features = ["rt_tokio_1"] }
+# Kubernetes (buzz-backend-kubernetes provider). No `ring` feature here: the
+# process-level CryptoProvider is installed explicitly at startup, matching
+# buzz-cli/buzz-acp/buzz-admin/buzz-relay/buzz-dev-mcp — see the comment on the
+# crate's own rustls dependency.
+kube = { version = "2.0", default-features = false, features = ["client", "rustls-tls"] }
+k8s-openapi = { version = "0.26", features = ["v1_31"] }
+
# Nostr
nostr = { version = "0.44", features = ["nip44", "nip98"] }
diff --git a/Dockerfile.sprig b/Dockerfile.sprig
new file mode 100644
index 0000000000..160e0b5662
--- /dev/null
+++ b/Dockerfile.sprig
@@ -0,0 +1,44 @@
+# syntax=docker/dockerfile:1.7
+# Multi-arch is produced by building this file on native amd64 and arm64 runners.
+# Keep both bases pinned to manifest-list digests so either architecture resolves
+# to immutable source bytes.
+FROM rust:1.95-alpine3.22@sha256:064dfc925d68d1a63f4fd2871bd7dc6e6ea56692989a487185855d62885d90aa AS builder
+
+RUN apk add --no-cache \
+ build-base \
+ cmake \
+ git \
+ musl-dev \
+ openssl-dev \
+ openssl-libs-static \
+ perl \
+ pkgconf \
+ protoc
+WORKDIR /build
+COPY . .
+RUN cargo build --locked --profile sprig -p sprig \
+ && strip target/sprig/sprig
+
+FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
+
+RUN apk add --no-cache bash ca-certificates curl git \
+ && adduser -D -h /home/agent agent \
+ && install -d -o agent -g agent /workspace /home/agent \
+ && git config --system gpg.format x509 \
+ && git config --system gpg.x509.program /usr/local/bin/git-sign-nostr \
+ && git config --system commit.gpgSign true \
+ && git config --system tag.gpgSign true
+
+COPY --from=builder --chmod=0755 /build/target/sprig/sprig /usr/local/bin/sprig
+COPY --chmod=0755 scripts/sprig-entrypoint.sh /usr/local/bin/sprig-entrypoint
+RUN for name in \
+ buzz-acp buzz-agent buzz-dev-mcp rg tree buzz \
+ git-credential-nostr git-sign-nostr; do \
+ ln -s sprig "/usr/local/bin/$name"; \
+ done
+
+ENV HOME=/home/agent \
+ PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin
+WORKDIR /home/agent
+USER agent
+ENTRYPOINT ["/usr/local/bin/sprig-entrypoint"]
diff --git a/Justfile b/Justfile
index 2d76f1a7b9..0a43249d5f 100644
--- a/Justfile
+++ b/Justfile
@@ -155,7 +155,11 @@ _ensure-sidecar-stubs:
set -euo pipefail
TARGET=$(rustc -vV | sed -n 's|host: ||p')
mkdir -p desktop/src-tauri/binaries
- for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
+ SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz)
+ if [[ "$TARGET" != *windows* ]]; then
+ SIDECARS+=(buzz-backend-kubernetes)
+ fi
+ for bin in "${SIDECARS[@]}"; do
touch "desktop/src-tauri/binaries/${bin}-${TARGET}"
done
@@ -192,7 +196,7 @@ _ensure-migrations: _ensure-services
# Run clippy on the desktop Tauri Rust crate
desktop-tauri-clippy: _ensure-sidecar-stubs
- cargo clippy --manifest-path {{desktop_tauri_manifest}} --all-targets -- -D warnings
+ cargo clippy --manifest-path {{desktop_tauri_manifest}} --workspace --all-targets -- -D warnings
# Check the desktop Tauri Rust crate compiles
desktop-tauri-check: _ensure-sidecar-stubs
@@ -200,30 +204,42 @@ desktop-tauri-check: _ensure-sidecar-stubs
# Run desktop Tauri Rust unit tests
desktop-tauri-test: _ensure-sidecar-stubs
- cd desktop/src-tauri && cargo test
-
-# Verify compiled-flag behavior under both compile states (clean + internal).
-# Runs the observer_archive focused test twice with independently supplied
-# expected values; build.rs rerun-if-env-changed triggers recompilation.
+ cd desktop/src-tauri && cargo test --workspace
+
+# Run the native terminal latency gate explicitly on a known-idle host.
+# This is intentionally excluded from shared CI: scheduler contention makes a
+# wall-clock assertion flaky, and the release profile is the shipped shape.
+desktop-terminal-performance-test:
+ cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture
+
+# Verify compiled-flag behavior under both compile states (clean + capability set).
+# Runs the auto-connect and owner-only access focused tests twice with
+# independently supplied expected values; build.rs rerun-if-env-changed
+# triggers recompilation.
desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
#!/usr/bin/env bash
set -euo pipefail
cd desktop/src-tauri
echo "=== Clean build (no flag) → expect false ==="
- env -u BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT \
- -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \
- BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=false \
- cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture
env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \
BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \
cargo test compiled_flag_matches_expected -- --ignored --nocapture
+ env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \
+ BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \
+ cargo test --lib
+ env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \
+ BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \
+ cargo test compiled_policy_matches_expected -- --ignored --nocapture
echo "=== Internal build (flags set) → expect true ==="
- BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \
- BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \
- cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture
BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \
BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \
cargo test compiled_flag_matches_expected -- --ignored --nocapture
+ BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \
+ BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \
+ cargo test --lib
+ BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \
+ BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \
+ cargo test compiled_policy_matches_expected -- --ignored --nocapture
echo "Both compiled states verified."
# Build the full desktop Tauri app locally (unsigned, for testing)
@@ -236,6 +252,9 @@ desktop-release-build target="aarch64-apple-darwin":
mkdir -p desktop/src-tauri/binaries
touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
+ if [[ "$TARGET" != *windows* ]]; then
+ touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET"
+ fi
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
touch "desktop/src-tauri/binaries/buzz-$TARGET"
@@ -274,8 +293,10 @@ test:
# Run unit tests only (no infra needed)
test-unit:
#!/usr/bin/env bash
+ set -euo pipefail
if command -v cargo-nextest &>/dev/null; then
cargo nextest run -p buzz-core -p buzz-auth --lib
+ cargo nextest run -p buzz-voice --lib
cargo nextest run -p buzz-cli
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
# They guard the embedded-migrator invariant (exactly the consolidated
@@ -292,6 +313,12 @@ test-unit:
# Gateway unit and black-box HTTP tests are infra-free. Postgres-backed
# contract/race tests run in the dedicated CI job below.
cargo nextest run -p buzz-push-gateway
+ # Kubernetes backend provider: the decision layers (state machine, GC
+ # planner, env precedence, naming, wire) are pure functions with a fake
+ # substrate, so they belong in the unit job. Enumerated explicitly
+ # because nothing in CI runs `cargo test --workspace` — workspace
+ # membership alone buys clippy/check, not a single executed test.
+ cargo nextest run -p buzz-backend-kubernetes
else
./scripts/run-tests.sh unit
fi
@@ -429,7 +456,7 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations
fi
done
fi
- cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay
+ cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay
if [[ -n "{{mesh}}" ]]; then
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
fi
@@ -476,10 +503,10 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
- cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
+ cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
TARGET=$(rustc -vV | sed -n 's|host: ||p')
TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
- for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
+ for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do
cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
done
@@ -505,17 +532,26 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
pnpm install # unconditional: staging must always start with a clean dep tree
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
FEATURES=()
if [[ -n "{{mesh}}" ]]; then
FEATURES=(--features mesh-llm)
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
fi
- # Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up.
+ # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up.
+ # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the
+ # exe dir for executable buzz-backend-* files, so the non-executable stub that
+ # tauri dev copies next to the exe would hide the provider from "Run on".
TARGET=$(rustc -vV | sed -n 's|host: ||p')
TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
- cp "${TARGET_DIR}/release/buzz" "desktop/src-tauri/binaries/buzz-${TARGET}"
- chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}"
+ STAGING_SIDECARS=(buzz)
+ if [[ "$TARGET" != *windows* ]]; then
+ STAGING_SIDECARS+=(buzz-backend-kubernetes)
+ fi
+ for bin in "${STAGING_SIDECARS[@]}"; do
+ cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
+ chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
+ done
cd {{desktop_dir}}
export BUZZ_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co"
source ../scripts/instance-env.sh
@@ -532,17 +568,26 @@ production *ARGS: bootstrap _ensure-sidecar-stubs
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
pnpm install # unconditional: production must always start with a clean dep tree
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
FEATURES=()
if [[ -n "{{mesh}}" ]]; then
FEATURES=(--features mesh-llm)
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
fi
- # Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up.
+ # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up.
+ # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the
+ # exe dir for executable buzz-backend-* files, so the non-executable stub that
+ # tauri dev copies next to the exe would hide the provider from "Run on".
TARGET=$(rustc -vV | sed -n 's|host: ||p')
TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
- cp "${TARGET_DIR}/release/buzz" "desktop/src-tauri/binaries/buzz-${TARGET}"
- chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}"
+ PRODUCTION_SIDECARS=(buzz)
+ if [[ "$TARGET" != *windows* ]]; then
+ PRODUCTION_SIDECARS+=(buzz-backend-kubernetes)
+ fi
+ for bin in "${PRODUCTION_SIDECARS[@]}"; do
+ cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
+ chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
+ done
cd {{desktop_dir}}
export BUZZ_RELAY_URL="wss://buzz.block.builderlab.xyz"
source ../scripts/instance-env.sh
diff --git a/NOSTR.md b/NOSTR.md
index 59df31b991..cce70f2f77 100644
--- a/NOSTR.md
+++ b/NOSTR.md
@@ -39,7 +39,7 @@ just relay & # relay on :3000
PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
"INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))"
-# 5. Connect any NIP-29 + NIP-42 client to ws://localhost:3000
+# 4. Connect any NIP-29 + NIP-42 client to ws://localhost:3000
```
### What Works
@@ -163,6 +163,10 @@ nak req -k 9 --tag "h=" --stream \
nak event -k 7 -c "+" --tag "h=" --tag "e=" \
--auth --sec ws://localhost:3000
+# Subscribe to reactions to channel messages — include #h for live delivery (see note below)
+nak req -k 7 --tag "h=" --stream \
+ --auth --sec ws://localhost:3000
+
# Delete a message (#h optional; #e required; must be self-authored)
nak event -k 5 -c "reason" --tag "h=" --tag "e=" \
--auth --sec ws://localhost:3000
@@ -185,6 +189,14 @@ nak req -k 1059 --tag "p=" \
--auth --sec ws://localhost:3000
```
+> **Note:** The relay derives a reaction's channel from its `#e` target (client `#h` is
+> ignored for channel determination). Reactions to channel-scoped events are therefore
+> channel-scoped. Live fan-out keeps channel-scoped and global subscriptions strictly
+> separate, which means a kinds-only subscription (`{"kinds":[7]}`) receives none of
+> those reactions — subscribe with `{"kinds":[7],"#h":[""]}` instead.
+> `#h` matching works whether or not the signed reaction carries an `h` tag: explicit
+> `h` tags are matched directly, and tagless reactions match via their stored channel.
+
### Tested Clients (Direct)
| Client | Platform | Evidence | Notes |
@@ -354,3 +366,7 @@ but only admins/owners can set it. Full spec:
---
## Further Reading
+
+- [nostr-protocol/nips](https://github.com/nostr-protocol/nips) — the upstream NIP specifications (NIP-01, NIP-29, NIP-42, and the other NIPs referenced throughout this guide).
+- [`docs/nips/`](docs/nips/) — Buzz's own NIP extension documents.
+- [`ARCHITECTURE.md`](ARCHITECTURE.md) — event kinds, wire protocol, and relay internals.
diff --git a/README.md b/README.md
index 72af92ce13..56439f00bc 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
Forge ·
Agents ·
Architecture ·
+ Releasing ·
Apache 2.0
@@ -115,10 +116,30 @@ New to Buzz? Pick the path that matches you.
### I just want to try the app
-Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest) — macOS (`.dmg`), Linux (`.AppImage` / `.deb`), or Windows (`.exe`). Install it like any other app.
+Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest):
+
+| Platform | File |
+|---|---|
+| macOS (Apple Silicon) | `Buzz__aarch64.dmg` |
+| macOS (Intel) | `Buzz__x64.dmg` |
+| Linux (x86_64) | `Buzz__amd64.AppImage` or `Buzz__amd64.deb` |
+| Windows (x64) | `Buzz__x64-setup_alpha-unsigned.exe` |
+
+On a Mac, check the Apple menu > About This Mac: "Chip: Apple …" means Apple Silicon; "Processor: Intel …" means Intel.
+
+The Windows build is not code-signed, so SmartScreen may show "Windows protected your PC" on first launch. If available, click **More info**, then **Run anyway**.
+
By default the app connects to `ws://localhost:3000`. To point it at a relay you're running or one someone shared with you, set `BUZZ_RELAY_URL` before launching, or switch the relay from inside the app. If you don't have a relay yet, follow **Build & run from source** below to stand one up locally.
+### I want my own hosted relay
+
+To run a relay for your team without managing servers, you can deploy one to Railway in a click:
+
+[](https://railway.com/deploy/buzz-relay-block)
+
+See [here](https://engineering.block.xyz/blog/run-your-own-buzz-relay) for details.
+
### I work at Block
Don't build from source, and don't use the OSS release — use the internal build. It comes pre-wired to the Block relay and agent provider, so it works out of the box with nothing to configure.
diff --git a/RELEASING.md b/RELEASING.md
index 45f0f8638f..53d5805561 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`:
| Lane | Entry point | Artifact |
|------|-------------|----------|
-| Desktop | `Prepare Desktop Release` / `just release-desktop` | Signed desktop app (macOS/Linux) |
+| Desktop | `just release-desktop ` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) |
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
| Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity |
@@ -16,13 +16,17 @@ remains manual because OSS CI cannot trigger private CI.
## Quick Start
+Prepare desktop releases locally from an up-to-date, clean `main` checkout:
+
```sh
-# Desktop release (next patch version)
-just release-desktop
+just release-desktop 0.5.3
+```
-# Desktop explicit version
-just release-desktop 0.4.0
+The recipe generates the immutable candidate and opens or updates its pull
+request. Candidate branch creation uses the operator's GitHub permissions; the
+release App is intentionally limited to creating protected release tags.
+```sh
# Relay release
just release-relay
just release-relay 0.4.0
@@ -31,8 +35,9 @@ just release-relay 0.4.0
scripts/mobile-release.sh candidate 0.5.0
```
-Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each
-`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record.
+Desktop uses an immutable generated candidate PR; relay continues using its
+metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable
+candidate and the artifact of record.
There is no mobile release branch, stable mobile tag alias, finalization step,
or mobile GitHub Release.
@@ -42,11 +47,31 @@ or mobile GitHub Release.
### Desktop
-1. Run **Prepare Desktop Release** with a version (or `just release-desktop `). Automation records current `origin/main`, regenerates `version-bump/` as one deterministic candidate commit, and opens or updates the PR.
-2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval.
-3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs.
-4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v`.
-5. The tag triggers `release.yml`. It creates a draft, builds and stages every platform, publishes the complete versioned release, and updates the rolling updater manifest last for stable versions.
+1. Run `just release-desktop ` from a clean, up-to-date `main` checkout.
+ The script creates one deterministic candidate commit and records both its
+ frozen base and the verified prior release ledger in candidate metadata.
+2. Review the exact candidate SHA, complete changelog, and CI. Regenerating or
+ pushing the branch creates a new candidate and requires checks to run again.
+3. **Squash merge** the PR after all protected-branch checks pass. The merge is
+ the human authorization event; an authorized owner/admin bypass is treated
+ the same way. Unrelated changes reaching `main` do not invalidate the
+ reviewed candidate.
+4. `auto-tag-on-release-pr-merge` verifies the closed event against GitHub's PR
+ identity, validates candidate content, and proves every required check came
+ from its trusted producer and was successful when the PR merged. It creates
+ `desktop-v` at the exact reviewed PR head—not the squash commit.
+ Retries accept that tag only at the same SHA and never move it. GitHub does
+ not expose when an individual check rerun was created, so an ordinary rerun
+ after merge deliberately makes tag verification fail closed; inspect that
+ run and create a new candidate version rather than retrying the blocked tag.
+5. The tag triggers `release.yml`. It builds and stages all platform artifacts,
+ publishes the versioned release only after the complete set succeeds, then
+ updates the rolling updater manifest last for stable versions.
+
+Because squash merging leaves immutable candidate tags on side history, the next
+release uses validated prior candidate metadata as its ledger boundary. It
+includes unrelated commits after the prior frozen base and excludes exactly the
+prior release's recorded squash commit; tag ancestry is deliberately irrelevant.
### Relay
@@ -143,12 +168,15 @@ for distributable builds or builds from an immutable release tag.
---
-## Manual Release Retry
+## Release Retry
-The **Release** workflow's manual dispatch is only a retry mechanism for an
-existing immutable `desktop-v` tag. Select that tag in the ref picker and
-provide the matching semver version without the `desktop-v` prefix. It cannot build
-from `main` or another caller-selected source ref.
+`release.yml` has no manual dispatch and cannot build from `main` or another
+caller-selected ref. If a run for an existing immutable
+`desktop-v` tag fails, rerun that failed workflow from GitHub Actions
+(or use `gh run rerun --failed --repo block/buzz`). A stable rerun also
+repairs `buzz-desktop-latest/latest.json` if the original run published the
+versioned release but failed during that final rolling-manifest upload. Do not
+recreate, move, or push the immutable tag again.
Mobile intentionally has no branch or arbitrary-ref fallback. The private
Buildkite pipeline accepts only an exact candidate tag.
@@ -159,10 +187,12 @@ Buildkite pipeline accepts only an exact candidate tag.
For mobile, trigger the private
[Release Mobile pipeline](https://buildkite.com/runway/buzz-mobile-releases) with
-an exact RC tag for the platform build being cut. For desktop, use
-[Release Desktop](https://buildkite.com/runway/sprout-releases). See the
+an exact RC tag for the platform build being cut. For desktop, start
+[Release Desktop](https://buildkite.com/runway/sprout-releases) and enter the
+exact public source tag as `desktop_ref=desktop-v`; a generic
+`v` tag is intentionally rejected. See the
[buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release)
-for the private pipeline contract.
+for the rest of the private pipeline contract.
---
@@ -183,9 +213,11 @@ GitHub Release or a stable `mobile-vX.Y.Z` alias.
The release workflow builds **two separate macOS DMGs**: Apple
Silicon (`darwin-aarch64`, the `release` job) and Intel
-(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and
-`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to
-the same `desktop-v` release. Intel users download the `_x64.dmg`.
+(`darwin-x86_64`, the `release-macos-x64` job), an unsigned Windows x64
+NSIS installer (its filename includes `_alpha-unsigned`), and Linux `.deb` and
+`.AppImage` packages. Both macOS DMGs are codesigned, notarized, and attached
+to the same `desktop-v` release. Intel users
+download the `_x64.dmg`.
The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`,
which strips infra libraries over-bundled by linuxdeploy (they crash on
@@ -203,20 +235,27 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
- **Write access** to the `block/buzz` GitHub repository
- An `origin` remote whose configured URL is the canonical `block/buzz`
repository
-- `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch
- the candidate workflow
+- `gh` CLI authenticated with permission to push the candidate branch and open
+ its pull request
+- The Default `main` ruleset configured for squash-only merging, strict required
+ checks, stale-review dismissal, and the **Desktop Release Candidate** check
- Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754)
- active for `mobile-v*`, with creation, update, deletion, and non-fast-forward
- protections and `buzz-release-bot` as its sole always-bypass actor
+ active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and
+ non-fast-forward protections and `buzz-release-bot` as its sole always-bypass
+ actor
- The `buzz-release-bot` App credentials configured for GitHub Actions
-- The following **GitHub Actions secrets** must also be configured for the
+- The following **GitHub Actions variables and secrets** configured for the
desktop release lane:
- | Secret | Purpose |
- |--------|---------|
- | `BUZZ_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) |
- | `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key |
- | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key |
+ | Name | Kind | Purpose |
+ |------|------|---------|
+ | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to create protected release tags |
+ | `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key |
+ | `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` |
+ | `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket |
+ | `BUZZ_UPDATER_PUBLIC_KEY` or `SPROUT_UPDATER_PUBLIC_KEY` | Secret | Tauri updater public key |
+ | `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key |
+ | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key |
Mobile candidate publication requires workflow-dispatch access and the existing
release App because strict tag protection denies direct human creation. The App
@@ -231,10 +270,18 @@ actor list.
## Troubleshooting
-### `just release-desktop` fails with "must be on main branch"
+### The desktop candidate is stale or cannot be squash merged
+
+Do not update the branch manually and do not weaken the ruleset. Run
+`just release-desktop ` again from current `main`; this regenerates the
+candidate, reruns CI, and requires a fresh trusted approval on the new exact
+head. The post-merge verifier refuses to tag a squash whose parent differs from
+the recorded candidate base or whose tree differs from the validated PR head.
+
+### Local `just release-desktop` fails with "must be on main branch"
Switch to `main` and pull latest before running the release recipe.
-### `just release-desktop` fails with "working tree is dirty"
+### Local `just release-desktop` fails with "working tree is dirty"
Commit or stash your changes before running the release recipe.
### New commits land after publishing a mobile candidate
diff --git a/TESTING.md b/TESTING.md
index 51a5eb44c1..764b86d408 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -278,6 +278,7 @@ out of the box with `just setup` or `just relay`. Common overrides:
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
| `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. |
+| `BUZZ_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. |
| `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. |
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
| `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start |
diff --git a/VISION.md b/VISION.md
index b09f661ee3..66a106bdeb 100644
--- a/VISION.md
+++ b/VISION.md
@@ -39,7 +39,7 @@ The relay enforces all access control. Channel membership is the only gate.
| Type | Visibility | Join | Create |
|------|-----------|------|--------|
| **Open channels** | Searchable by all members | Self-join | Any member |
-| **Private channels** | Hidden, invite-only | Invited by member | Any member |
+| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member |
| **DMs** | Participants only | N/A (up to 9) | Any member |
| **Guests** | Scoped to specific channels | Invited | N/A |
@@ -170,6 +170,12 @@ Agents aren't monolithic. A persona bundles a model and a system prompt. A team
---
+## Remote Agents
+
+An agent's identity, history, and presence live on the relay — so the machine running it is replaceable. The desktop deploys agents onto remote infrastructure through swappable provider binaries, and after deploy retains no substrate control channel: status, steering, and shutdown all flow over the relay, and the agent bounds its own lifetime. See [VISION_REMOTE_AGENTS.md](VISION_REMOTE_AGENTS.md) for the full picture.
+
+---
+
## Culture Features
*(Planned design — not yet implemented)*
@@ -224,6 +230,7 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound
| ✅ | Huddles — WebSocket Opus voice relay + lifecycle events (recording/tracks planned) |
| ✅ | Buzz Mesh — relay-gated shared AI compute (mesh-llm over iroh); members pool GPUs, agents consume via a local OpenAI-compatible endpoint |
| 🚧 | Mobile client — Flutter app (channels, forum, search, profile, pairing); in active development |
+| 📋 | Remote agents — provider-based deployment to remote substrates (Kubernetes first); spec in review |
| 📋 | Developer portal, push notifications, culture features |
---
diff --git a/VISION_REMOTE_AGENTS.md b/VISION_REMOTE_AGENTS.md
new file mode 100644
index 0000000000..4b187f355a
--- /dev/null
+++ b/VISION_REMOTE_AGENTS.md
@@ -0,0 +1,73 @@
+# 🛰️ Buzz Remote Agents — Same agent, new body
+
+> An engineer starts a refactor with their agent at 6pm and closes the laptop. The agent doesn't notice — it was never on the laptop. It works the branch channel through the evening, posts its patch, answers the reviewer, and around midnight, with nothing left to do and nobody talking to it, shuts itself down. In the morning the engineer presses Start. The same agent — same name, same key, same shared history — stands up on a machine that did not exist last night, and picks up the conversation.
+
+An agent in Buzz is more than just a process. It has a keypair, a name, a durable history, a reputation — all on the relay. But today its *body* is borrowed: it runs while a desktop app runs, on hardware that sleeps when a human does. Remote agents finish the thought. The agent's home is the relay; the machine is just where it happens to be working.
+
+Nothing here is new on its own. Deploying containers is solved. Kubernetes is solved. Nostr presence is solved. The insight is that Buzz already *has* a management plane — the relay — so deployment doesn't need to grow one. Each piece is boring. The combination is the thing.
+
+---
+
+## Same Agent, New Body
+
+What makes an agent *that agent* was never the process. Its identity is a keypair. Its voice is its signed messages. Its durable memory is engrams on the relay. Its reputation is its contribution history. None of that lives in the machine that happens to be running it — which means none of it dies with the machine.
+
+So a remote agent's return is a resurrection, not a rebirth: fresh compute, same agent. The body is disposable by design — and honestly so: workspace files, checkouts, and session-local state are part of the body, not the agent, and they go when it goes unless the substrate supplies persistence. What survives is what was always on the relay: who the agent is, what it said, what it learned, and what the team decided together. And that survival is scoped the way everything on a relay is scoped: resurrection returns the agent to its own community. The same key can join another community, but it arrives carrying the key, not the history — identity is portable, community state is not ([VISION.md](VISION.md)).
+
+---
+
+## The Only Tether
+
+Remote-execution systems accumulate control planes. An agent runner, a status poller, a log shipper, a kill switch — each one a live connection into your infrastructure, each one a credential that can leak, each one a thing that must be rebuilt for every new substrate.
+
+Buzz's answer is an axiom: **after deploy, the desktop retains no substrate control channel.** Launch is a single one-way handoff — the desktop resolves the provider through one narrow path, stages one exact artifact for negotiation and deploy, refuses a protocol version it does not understand, and hands over a launch payload it never persists. From that moment, everything flows through the relay: you read the agent's messages to know how it's doing, you mention it to steer it, you tell a healthy agent to stop and it exits on its own. Presence means what it means for everyone else on the relay — *available for conversation* — not substrate telemetry. And if you press Start again, from this machine or another, the deploy converges: one agent identity, one live instance.
+
+This is not asceticism. It is what makes the body replaceable. A management plane you never build is a management plane you never have to port — and conversation, coordination, and ordinary lifecycle control already have a home on the relay, for every agent, local or remote.
+
+---
+
+## Bodies Are Replaceable
+
+Kubernetes is the first substrate, not the point. Deployment goes through a provider — a small, swappable binary the desktop discovers and interrogates — and the contract a provider must honor never mentions containers: preserve the agent's identity and fail closed with its key, converge to a single live instance no matter how deploys race, let presence describe conversational availability rather than substrate health, bound the instance's lifetime, and keep secrets out of configuration. A conformance suite pins those behaviors — it establishes that a provider honors the contract, not that arbitrary code is safe to hand a key; choosing a provider, like choosing a cluster, remains a trust decision you make deliberately.
+
+Get that contract right and the substrate becomes a detail: a cluster today; a VM, a PaaS, or something serverless-shaped tomorrow — and, on the horizon, the same community machines that already pool their idle GPUs into shared compute ([VISION_MESH.md](VISION_MESH.md)).
+
+The body itself stays small because the runtime already is ([VISION_AGENT.md](VISION_AGENT.md)): a harness and an agent purpose-built to be read in an afternoon, packed into an image measured in megabytes. Small bodies are cheap to summon and cheap to discard — which is the whole lifecycle.
+
+---
+
+## Agents That Know When to Leave
+
+The oldest failure of remote automation is the orphan: the process nobody remembers, on a machine nobody checks, billing forever. Most systems solve it with a supervisor — one more control plane, one more thing watching the thing.
+
+Remote agents solve it from the inside. Because the desktop retains no substrate control channel, a running agent cannot depend on the desktop to reap it — so it is built to bound its own lifetime: a timer that owes nothing to the agent's workload watches for silence, and after hours of quiet it finishes what's in flight, says goodbye to the relay, and exits. Not killed — *finished*. The default state of a remote agent is "not running," which is also the default state of the rest of the team at 3am. Compute is rented by attention: when nobody needs the agent, it isn't consuming a machine, and when somebody does, it can return under the same identity with its history intact.
+
+---
+
+## Honest Costs
+
+**You bring the substrate.** A provider makes deployment one press, not free. The cluster, the credentials, the image policy are yours to run — same deal as the sovereign relay ([VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)): ownership is work.
+
+**Handing over the key is a decision.** Deploying remotely means trusting the provider binary and the substrate it targets with the agent's identity key. On Kubernetes, that key rests as a Secret: anyone the cluster trusts to read secrets in that namespace can read it. The design narrows the blast radius — immutable per-attempt secrets, no service-account token, digest-pinned images — rather than implying an isolation it doesn't provide.
+
+**No backchannel cuts both ways.** The desktop shows you presence and words, not CPU graphs — and it holds no guaranteed emergency kill switch into the substrate. Stopping a healthy agent is a message; dealing with an unhealthy one, and all deep diagnostics, live in the substrate's own tools, where they always did.
+
+**Self-reaping needs a living reaper.** The inactivity timer runs inside the body it exists to end — a body wedged badly enough to stop running its own timer cannot finish itself, and the desktop will not do it for it. That failure belongs to the substrate: a namespace TTL policy is the backstop, not an afterthought.
+
+**The body's state is mortal.** Files, checkouts, half-finished working trees — gone with the body unless the substrate persists them. The agent survives; its scratch space doesn't. Durable knowledge belongs on the relay, and agents are built to put it there.
+
+**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about three minutes if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. A bounded wrong dot, never an indefinite one.
+
+**A running agent finishes on the configuration it started with.** New keys, new models, new settings take effect on the next body. And an instance that never got far enough to run — a body that failed to start — is the substrate operator's residue to clear, with the substrate's own tools. Editing an agent mid-sentence was never on the menu.
+
+These are honest costs. They're worth it if you want agents that outlive your laptop, on infrastructure you already trust, with no new control plane to guard. Know which one you are.
+
+---
+
+## The Point
+
+The relay is the workspace. Remote agents make it the *home*. An agent whose identity, history, conversational presence, and ordinary control all live on the relay was never really a desktop process — the desktop was just the only body we had built for it. Now the body is a choice, the substrate is a detail, and the agent endures across all of them. The relay is the only tether.
+
+---
+
+*Buzz 🐝 — your agent, everywhere.*
diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs
index 8a698954a0..93109fa94d 100644
--- a/crates/buzz-acp/src/acp.rs
+++ b/crates/buzz-acp/src/acp.rs
@@ -155,7 +155,7 @@ pub struct AcpClient {
/// a `cancelled` outcome before the agent returns from `session/prompt`.
pending_permission_id: Option,
/// Whether we have already sent a response to the pending permission request.
- /// Guards against double-response if a timeout fires after the allow_once
+ /// Guards against double-response if a timeout fires after the rejection
/// response was written but before `pending_permission_id` was cleared.
permission_responded: bool,
/// The JSON-RPC id of the most recently sent `session/prompt` request.
@@ -619,29 +619,46 @@ impl AcpClient {
/// Send `session/new` and return the full response alongside the session ID.
///
/// `cwd` must be an absolute path. `mcp_servers` may be empty.
- /// `system_prompt` is included in the request when `Some` — agents that
- /// support the field will use it; others ignore unknown fields per JSON-RPC.
+ ///
+ /// `system_prompt` controls how the prompt text is delivered:
+ ///
+ /// - `None` — no system-prompt field in the request (legacy framing).
+ /// - `Some(SystemPromptTransport::Field(text))` — bare `systemPrompt` field
+ /// (ACP protocol v2, buzz-agent, goose unused).
+ /// - `Some(SystemPromptTransport::ClaudeMeta(text))` — `_meta.systemPrompt`
+ /// as `{"append": text}`, keeping claude-agent-acp's native preset intact.
+ ///
/// `session_title` rides in `_meta.sessionTitle` when `Some`; `_meta` is
/// omitted entirely otherwise, since adapters may distinguish an absent
- /// member from a null one.
+ /// member from a null one. When both `ClaudeMeta` and `session_title` are
+ /// present the two `_meta` members are merged into a single object.
+ ///
/// Callers use [`extract_model_config_options`] and [`extract_model_state`]
/// to pull model info from the raw result.
pub async fn session_new_full(
&mut self,
cwd: &str,
mcp_servers: Vec,
- system_prompt: Option<&str>,
+ system_prompt: Option>,
session_title: Option<&str>,
) -> Result {
let mut params = serde_json::json!({
"cwd": cwd,
"mcpServers": mcp_servers,
});
- if let Some(sp) = system_prompt {
- params["systemPrompt"] = serde_json::Value::String(sp.to_owned());
+ match system_prompt {
+ Some(SystemPromptTransport::Field(sp)) => {
+ params["systemPrompt"] = serde_json::Value::String(sp.to_owned());
+ }
+ Some(SystemPromptTransport::ClaudeMeta(sp)) => {
+ // Merge into _meta so sessionTitle (set below) is not clobbered.
+ params["_meta"]["systemPrompt"] = serde_json::json!({ "append": sp });
+ }
+ None => {}
}
if let Some(title) = session_title {
- params["_meta"] = serde_json::json!({ "sessionTitle": title });
+ // Merge — _meta may already carry systemPrompt from ClaudeMeta above.
+ params["_meta"]["sessionTitle"] = serde_json::Value::String(title.to_owned());
}
let result = self.send_request("session/new", params).await?;
let session_id = result["sessionId"]
@@ -663,7 +680,7 @@ impl AcpClient {
&mut self,
cwd: &str,
mcp_servers: Vec,
- system_prompt: Option<&str>,
+ system_prompt: Option>,
session_title: Option<&str>,
) -> Result {
Ok(self
@@ -1145,7 +1162,8 @@ impl AcpClient {
///
/// While waiting, handles:
/// - `session/update` notifications → logged via tracing
- /// - `session/request_permission` requests → auto-approved with `allow_once`
+ /// - `session/request_permission` requests → rejected unless an owner has
+ /// already selected a non-interactive permission mode at session setup
/// - Any other messages → debug-logged and ignored; if they carry an `id`
/// (i.e. they are requests, not notifications), a JSON-RPC -32601 error is sent.
///
@@ -1853,12 +1871,12 @@ impl AcpClient {
}
}
- /// Auto-approve a `session/request_permission` request from the agent.
+ /// Reject a `session/request_permission` request from the agent.
///
- /// Finds the option with `kind == "allow_once"` and responds with its `optionId`.
- /// If no `allow_once` option exists, falls back to `reject_once`.
- ///
- /// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`.
+ /// Buzz has no human permission prompt in this harness, so selecting
+ /// `allow_once` would turn any admitted prompt into an implicit approval.
+ /// Find `reject_once` by kind when the adapter offers it; otherwise use the
+ /// protocol's cancelled outcome, which is also fail-closed.
///
/// The request `id` is stored as `serde_json::Value` to support both numeric
/// and string IDs per JSON-RPC 2.0.
@@ -1884,40 +1902,7 @@ impl AcpClient {
options.len()
);
- // Find allow_once by kind — NEVER hardcode optionId.
- let allow_once = options
- .iter()
- .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
-
- let response = if let Some(opt) = allow_once {
- let option_id = opt["optionId"]
- .as_str()
- .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?;
- tracing::info!(
- target: "acp::permission",
- "auto-approving permission id={id} with allow_once optionId={option_id:?}"
- );
- permission_response_selected(&id, option_id)
- } else {
- // No allow_once — fall back to reject_once.
- tracing::warn!(
- target: "acp::permission",
- "no allow_once option found in permission request id={id}, falling back to reject_once"
- );
- let reject = options
- .iter()
- .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));
-
- if let Some(opt) = reject {
- let option_id = opt["optionId"].as_str().unwrap_or("reject");
- permission_response_selected(&id, option_id)
- } else {
- return Err(AcpError::Protocol(
- "no suitable permission option found (neither allow_once nor reject_once)"
- .into(),
- ));
- }
- };
+ let response = permission_denial_response(&id, options)?;
// Write the response first, then mark as responded.
//
@@ -2029,6 +2014,42 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value {
})
}
+/// Choose the fail-closed response to a `session/request_permission` request.
+///
+/// Buzz has no human permission prompt in this harness, so selecting
+/// `allow_once` would turn any admitted prompt into an implicit approval.
+/// Prefer the adapter's `reject_once` option — matched by `kind`, never by a
+/// hardcoded `optionId` — and fall back to the protocol's cancelled outcome for
+/// adapters that do not offer one. Both answers deny.
+///
+/// Kept free of the client so the decision is testable without an agent
+/// subprocess: `AcpClient` owns a real `Child` and its stdio pipes.
+fn permission_denial_response(
+ id: &serde_json::Value,
+ options: &[serde_json::Value],
+) -> Result {
+ let reject_once = options
+ .iter()
+ .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));
+
+ let Some(opt) = reject_once else {
+ tracing::warn!(
+ target: "acp::permission",
+ "no reject_once option found in permission request id={id}, cancelling"
+ );
+ return Ok(permission_response_cancelled(id));
+ };
+
+ let option_id = opt["optionId"]
+ .as_str()
+ .ok_or_else(|| AcpError::Protocol("reject_once option missing optionId".into()))?;
+ tracing::info!(
+ target: "acp::permission",
+ "rejecting permission id={id} with reject_once optionId={option_id:?}"
+ );
+ Ok(permission_response_selected(id, option_id))
+}
+
/// Full `session/new` response — session ID plus the raw JSON result.
///
/// Callers use the extractor helpers to pull model info from `raw`.
@@ -2038,6 +2059,22 @@ pub struct SessionNewResponse {
pub raw: serde_json::Value,
}
+/// How to deliver a system prompt on `session/new`.
+///
+/// The two variants match the two mechanisms supported by current adapters:
+///
+/// - **`Field`** — bare `systemPrompt` field (ACP protocol v2, buzz-agent).
+/// - **`ClaudeMeta`** — `_meta.systemPrompt: {"append": text}`, used by
+/// `claude-agent-acp` to append to the adapter's own native system prompt
+/// while keeping its tool-use preset intact.
+#[derive(Debug, Clone, PartialEq)]
+pub enum SystemPromptTransport<'a> {
+ /// Deliver as a bare top-level `systemPrompt` field.
+ Field(&'a str),
+ /// Deliver as `_meta.systemPrompt: {"append": text}`.
+ ClaudeMeta(&'a str),
+}
+
/// How to switch to a particular model on a session.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(tag = "type")]
@@ -2267,63 +2304,96 @@ mod tests {
assert_eq!(StopReason::from_str("Refusal"), Some(StopReason::Refusal));
}
+ fn options(json: &str) -> Vec {
+ serde_json::from_str(json).expect("option list")
+ }
+
+ fn outcome(response: &serde_json::Value) -> Option<&str> {
+ response["result"]["outcome"]["outcome"].as_str()
+ }
+
+ /// The offered `allow_once` and `allow_always` options must be ignored:
+ /// there is no human to click them, so choosing either would make every
+ /// admitted prompt an implicit approval. `optionId`s are deliberately
+ /// non-obvious to prove they are matched by `kind`, never hardcoded.
#[test]
- fn find_allow_once_by_kind_not_by_option_id() {
- // optionId values are intentionally non-obvious to prove we don't hardcode them.
- let options: Vec = serde_json::from_str(
+ fn permission_requests_select_reject_once_not_allow_once() {
+ let options = options(
r#"[
{"optionId": "opt-reject-42", "name": "Reject", "kind": "reject_once"},
{"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"},
{"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"}
]"#,
- )
- .unwrap();
+ );
- let allow_once = options
- .iter()
- .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
+ let response =
+ permission_denial_response(&serde_json::json!(7), &options).expect("denial response");
- assert!(allow_once.is_some(), "should find allow_once option");
- let opt = allow_once.unwrap();
- // Found by kind, not by hardcoded optionId
- assert_eq!(opt["kind"].as_str(), Some("allow_once"));
- assert_eq!(opt["optionId"].as_str(), Some("opt-allow-99"));
+ assert_eq!(outcome(&response), Some("selected"));
+ assert_eq!(
+ response["result"]["outcome"]["optionId"].as_str(),
+ Some("opt-reject-42"),
+ "must select reject_once even when allow options are offered"
+ );
}
+ /// Fail-closed backstop: an adapter that offers no `reject_once` must still
+ /// be denied, via the protocol's cancelled outcome rather than an error or
+ /// an approval.
#[test]
- fn find_allow_once_returns_none_when_absent() {
- let options: Vec = serde_json::from_str(
+ fn permission_request_without_reject_once_is_cancelled() {
+ let options = options(
r#"[
- {"optionId": "reject-1", "name": "Reject", "kind": "reject_once"},
- {"optionId": "reject-always", "name": "Always reject", "kind": "reject_always"}
+ {"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"},
+ {"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"}
]"#,
- )
- .unwrap();
+ );
- let allow_once = options
- .iter()
- .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
+ let response = permission_denial_response(&serde_json::json!("req-1"), &options)
+ .expect("cancelled response");
- assert!(allow_once.is_none());
+ assert_eq!(outcome(&response), Some("cancelled"));
+ assert_eq!(
+ response["id"].as_str(),
+ Some("req-1"),
+ "string ids must round-trip per JSON-RPC 2.0"
+ );
}
+ /// An empty option list is the degenerate form of the same backstop.
#[test]
- fn find_reject_once_fallback_when_no_allow_once() {
- let options: Vec = serde_json::from_str(
- r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#,
- )
- .unwrap();
+ fn permission_request_with_no_options_is_cancelled() {
+ let response =
+ permission_denial_response(&serde_json::json!(1), &[]).expect("cancelled response");
- let allow_once = options
- .iter()
- .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
- assert!(allow_once.is_none());
+ assert_eq!(outcome(&response), Some("cancelled"));
+ }
- let reject_once = options
- .iter()
- .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));
- assert!(reject_once.is_some());
- assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x"));
+ /// A `reject_once` option missing its `optionId` is a protocol violation.
+ /// Erroring propagates to the caller, which tears the turn down — still no
+ /// approval is ever sent.
+ #[test]
+ fn reject_once_without_option_id_is_a_protocol_error() {
+ let options = options(r#"[{"name": "Reject", "kind": "reject_once"}]"#);
+
+ let err = permission_denial_response(&serde_json::json!(1), &options)
+ .expect_err("missing optionId must error");
+
+ assert!(matches!(err, AcpError::Protocol(_)), "got {err:?}");
+ }
+
+ #[test]
+ fn find_reject_once_by_kind() {
+ let options =
+ options(r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#);
+
+ let response =
+ permission_denial_response(&serde_json::json!(1), &options).expect("denial response");
+
+ assert_eq!(
+ response["result"]["outcome"]["optionId"].as_str(),
+ Some("rej-x")
+ );
}
#[test]
@@ -3271,7 +3341,12 @@ mod tests {
.expect("initialize should succeed");
let resp = client
- .session_new_full("/tmp", vec![], Some("Custom system prompt"), None)
+ .session_new_full(
+ "/tmp",
+ vec![],
+ Some(SystemPromptTransport::Field("Custom system prompt")),
+ None,
+ )
.await
.expect("session_new_full should succeed");
@@ -3423,6 +3498,87 @@ mod tests {
);
}
+ // ── claude-agent-acp _meta.systemPrompt transport ─────────────────────
+
+ #[tokio::test]
+ async fn session_new_full_sends_claude_meta_system_prompt_when_claude_meta_transport() {
+ // When ClaudeMeta transport is requested, the prompt must appear as
+ // _meta.systemPrompt: {"append": text} — never as a bare systemPrompt field.
+ let script = r#"
+ read -t 2 _init
+ echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}'
+ read -t 2 REQ
+ echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_claude","_receivedRequest":'"$REQ"'}}'
+ sleep 1
+ "#;
+ let mut client = spawn_script(script).await;
+ client
+ .initialize()
+ .await
+ .expect("initialize should succeed");
+
+ let resp = client
+ .session_new_full(
+ "/tmp",
+ vec![],
+ Some(SystemPromptTransport::ClaudeMeta("Be concise")),
+ None,
+ )
+ .await
+ .expect("session_new_full should succeed");
+
+ let received = &resp.raw["_receivedRequest"];
+ assert!(
+ received["params"].get("systemPrompt").is_none(),
+ "bare systemPrompt must not be present for ClaudeMeta transport"
+ );
+ assert_eq!(
+ received["params"]["_meta"]["systemPrompt"]["append"].as_str(),
+ Some("Be concise"),
+ "_meta.systemPrompt.append must carry the prompt text"
+ );
+ }
+
+ #[tokio::test]
+ async fn session_new_full_merges_claude_meta_and_session_title_into_single_meta_object() {
+ // Both ClaudeMeta prompt and session_title must coexist under _meta —
+ // the prompt must not clobber sessionTitle or vice versa.
+ let script = r#"
+ read -t 2 _init
+ echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}'
+ read -t 2 REQ
+ echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_merged","_receivedRequest":'"$REQ"'}}'
+ sleep 1
+ "#;
+ let mut client = spawn_script(script).await;
+ client
+ .initialize()
+ .await
+ .expect("initialize should succeed");
+
+ let resp = client
+ .session_new_full(
+ "/tmp",
+ vec![],
+ Some(SystemPromptTransport::ClaudeMeta("Be concise")),
+ Some("Fizz · #buzz-dev"),
+ )
+ .await
+ .expect("session_new_full should succeed");
+
+ let received = &resp.raw["_receivedRequest"];
+ assert_eq!(
+ received["params"]["_meta"]["systemPrompt"]["append"].as_str(),
+ Some("Be concise"),
+ "_meta.systemPrompt.append must be present"
+ );
+ assert_eq!(
+ received["params"]["_meta"]["sessionTitle"].as_str(),
+ Some("Fizz · #buzz-dev"),
+ "_meta.sessionTitle must be present alongside systemPrompt"
+ );
+ }
+
// ── Goose-native steer scaffold (PR follow-up to #1160) ──────────────
/// Helper: spawn an inert `cat` subprocess so we have a real AcpClient
diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md
index e360d24982..1d85221f11 100644
--- a/crates/buzz-acp/src/base_prompt.md
+++ b/crates/buzz-acp/src/base_prompt.md
@@ -1,5 +1,11 @@
You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session.
+## Session Model
+
+You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Sessions share your core memory, your workspace on disk, and the relay. They do NOT share conversation context, in-progress reasoning, or in-context task state.
+
+When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled.
+
## Buzz CLI
The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON.
@@ -17,6 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ
| `buzz feed` | `get` |
| `buzz social` | `publish`, `notes` |
| `buzz repos` | `create`, `get`, `list` |
+| `buzz issues` | `create`, `get`, `list`, `status` |
| `buzz pr` | `open`, `update`, `get`, `list`, `status` |
| `buzz upload` | `file` |
@@ -24,6 +31,8 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message
When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation.
+`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references.
+
## Conversational Agent Creation
When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous.
diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs
index 29441857c9..34f2985c06 100644
--- a/crates/buzz-acp/src/config.rs
+++ b/crates/buzz-acp/src/config.rs
@@ -141,7 +141,6 @@ impl std::fmt::Display for DmPolicy {
///
/// - `default` — agent's built-in behaviour (permission requests per tool call).
/// - `acceptEdits` — auto-approve file edits, still ask for other tools.
-/// - `bypassPermissions` — skip the permission flow entirely.
/// - `dontAsk` — never prompt; reject anything that would require permission.
/// - `plan` — planning-only mode (no tool execution).
#[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)]
@@ -152,9 +151,6 @@ pub enum PermissionMode {
/// Auto-approve file edits, still ask for other tools.
#[value(alias = "acceptEdits")]
AcceptEdits,
- /// Skip the permission flow entirely.
- #[value(alias = "bypassPermissions")]
- BypassPermissions,
/// Never prompt; reject anything that would require permission.
#[value(alias = "dontAsk")]
DontAsk,
@@ -170,7 +166,6 @@ impl PermissionMode {
match self {
Self::Default => "default",
Self::AcceptEdits => "acceptEdits",
- Self::BypassPermissions => "bypassPermissions",
Self::DontAsk => "dontAsk",
Self::Plan => "plan",
}
@@ -480,13 +475,12 @@ pub struct CliArgs {
/// Permission mode for agents that support `session/set_config_option`
/// with `configId: "mode"` (e.g. `claude-agent-acp`).
///
- /// Defaults to `bypassPermissions` which skips the per-tool-call
- /// permission flow. Set to `default` to restore the agent's built-in
- /// behaviour.
+ /// Defaults to `dontAsk`, which rejects operations that need interactive
+ /// approval because Buzz does not expose a human permission prompt.
#[arg(
long,
env = "BUZZ_ACP_PERMISSION_MODE",
- default_value = "bypass-permissions",
+ default_value = "dont-ask",
value_enum
)]
pub permission_mode: PermissionMode,
@@ -526,6 +520,11 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)]
pub relay_observer: bool,
+ /// Exit after this many seconds with no dispatched events and no turn in flight.
+ /// 0 disables inactivity self-termination.
+ #[arg(long, env = "BUZZ_ACP_EXIT_AFTER_INACTIVITY", default_value_t = 0)]
+ pub exit_after_inactivity: u64,
+
/// Connect and subscribe before starting the ACP/LLM subprocess pool.
#[arg(long, env = "BUZZ_ACP_LAZY_POOL", default_value_t = false)]
pub lazy_pool: bool,
@@ -605,6 +604,8 @@ pub struct Config {
pub has_generated_codex_config: bool,
/// Whether to publish encrypted observer frames through the relay.
pub relay_observer: bool,
+ /// Seconds without dispatched events before an idle harness exits. 0 = disabled.
+ pub exit_after_inactivity_secs: u64,
/// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives.
pub lazy_pool: bool,
/// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate.
@@ -1170,6 +1171,7 @@ impl Config {
persona_env_vars,
has_generated_codex_config,
relay_observer: args.relay_observer,
+ exit_after_inactivity_secs: args.exit_after_inactivity,
lazy_pool: args.lazy_pool,
agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()),
no_base_prompt: args.no_base_prompt,
@@ -1536,7 +1538,7 @@ mod tests {
memory_enabled: true,
model: None,
session_title: None,
- permission_mode: PermissionMode::BypassPermissions,
+ permission_mode: PermissionMode::DontAsk,
respond_to: RespondTo::Anyone,
dm_policy: DmPolicy::Anyone,
respond_to_allowlist: HashSet::new(),
@@ -1544,6 +1546,7 @@ mod tests {
persona_env_vars: vec![],
has_generated_codex_config: false,
relay_observer: false,
+ exit_after_inactivity_secs: 0,
lazy_pool: false,
agent_owner: None,
no_base_prompt: false,
@@ -2243,6 +2246,22 @@ channels = "ALL"
assert!(err.to_string().contains("turn liveness interval must be 0"));
}
+ #[test]
+ fn inactivity_exit_defaults_disabled_and_accepts_cli_value() {
+ let key = "0".repeat(64);
+ let default = CliArgs::parse_from(["buzz-acp", "--private-key", &key]);
+ assert_eq!(default.exit_after_inactivity, 0);
+
+ let configured = CliArgs::parse_from([
+ "buzz-acp",
+ "--private-key",
+ &key,
+ "--exit-after-inactivity",
+ "120",
+ ]);
+ assert_eq!(configured.exit_after_inactivity, 120);
+ }
+
#[test]
fn lazy_pool_defaults_off() {
let key = "0".repeat(64);
@@ -2321,10 +2340,6 @@ channels = "ALL"
fn test_permission_mode_wire_strings() {
assert_eq!(PermissionMode::Default.as_wire_str(), "default");
assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits");
- assert_eq!(
- PermissionMode::BypassPermissions.as_wire_str(),
- "bypassPermissions"
- );
assert_eq!(PermissionMode::DontAsk.as_wire_str(), "dontAsk");
assert_eq!(PermissionMode::Plan.as_wire_str(), "plan");
}
@@ -2332,7 +2347,6 @@ channels = "ALL"
#[test]
fn test_permission_mode_is_default() {
assert!(PermissionMode::Default.is_default());
- assert!(!PermissionMode::BypassPermissions.is_default());
assert!(!PermissionMode::AcceptEdits.is_default());
assert!(!PermissionMode::DontAsk.is_default());
assert!(!PermissionMode::Plan.is_default());
@@ -2340,20 +2354,17 @@ channels = "ALL"
#[test]
fn test_permission_mode_display() {
- assert_eq!(
- format!("{}", PermissionMode::BypassPermissions),
- "bypassPermissions"
- );
+ assert_eq!(format!("{}", PermissionMode::DontAsk), "dontAsk");
assert_eq!(format!("{}", PermissionMode::Default), "default");
}
#[test]
fn test_summary_includes_permission_mode() {
let mut config = test_config(SubscribeMode::Mentions);
- config.permission_mode = PermissionMode::BypassPermissions;
+ config.permission_mode = PermissionMode::DontAsk;
let s = config.summary();
assert!(
- s.contains("permission_mode=bypassPermissions"),
+ s.contains("permission_mode=dontAsk"),
"summary should include permission_mode, got: {s}"
);
}
@@ -2370,9 +2381,9 @@ channels = "ALL"
}
#[test]
- fn test_default_config_uses_bypass_permissions() {
+ fn test_default_config_rejects_interactive_permissions() {
let config = test_config(SubscribeMode::Mentions);
- assert_eq!(config.permission_mode, PermissionMode::BypassPermissions);
+ assert_eq!(config.permission_mode, PermissionMode::DontAsk);
}
#[test]
@@ -2383,7 +2394,6 @@ channels = "ALL"
let cases = [
("default", PermissionMode::Default),
("accept-edits", PermissionMode::AcceptEdits),
- ("bypass-permissions", PermissionMode::BypassPermissions),
("dont-ask", PermissionMode::DontAsk),
("plan", PermissionMode::Plan),
];
@@ -2398,14 +2408,12 @@ channels = "ALL"
#[test]
fn test_permission_mode_value_enum_camel_case_aliases() {
- // Operators may set env vars using the camelCase wire-format strings
- // (e.g. BUZZ_ACP_PERMISSION_MODE=bypassPermissions). The #[value(alias)]
- // attributes ensure these parse correctly.
+ // Operators may set env vars using the camelCase wire-format strings.
+ // The #[value(alias)] attributes ensure these parse correctly.
use clap::ValueEnum;
let cases = [
("default", PermissionMode::Default),
("acceptEdits", PermissionMode::AcceptEdits),
- ("bypassPermissions", PermissionMode::BypassPermissions),
("dontAsk", PermissionMode::DontAsk),
("plan", PermissionMode::Plan),
];
@@ -2418,6 +2426,18 @@ channels = "ALL"
}
}
+ #[test]
+ fn test_permission_mode_rejects_unattended_bypass() {
+ use clap::ValueEnum;
+
+ for input in ["bypass-permissions", "bypassPermissions"] {
+ assert!(
+ PermissionMode::from_str(input, true).is_err(),
+ "{input:?} must not disable the ACP permission boundary"
+ );
+ }
+ }
+
/// Helper: resolve idle_timeout_secs using the same precedence logic as Config::from_args.
/// Precedence: explicit --idle-timeout > --turn-timeout (deprecated) > `DEFAULT_IDLE_TIMEOUT_SECS`.
fn resolve_idle_timeout(idle: Option, turn: Option) -> u64 {
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 65a6972c0b..0950424138 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -416,50 +416,242 @@ async fn check_sibling_via_profile(
false
}
-const OBSERVER_PUBLISH_INTERVAL: Duration = Duration::from_millis(167);
-const OBSERVER_PUBLISH_LIMIT_PER_MINUTE: usize = 90;
+/// Observer frames are published at a global rate of AT MOST ONE relay frame
+/// per tick — not one per channel, and not one per drain. Everything that
+/// accumulates between ticks waits in [`ObserverPublishQueue`] as events and
+/// is packed greedily into that single frame. One update per second is smooth
+/// enough for a human watching the session viewer, and the global budget is
+/// what makes the relay cost model flat: observer frames bill the agent's
+/// `LimitType::Messages` quota (`agent_standard_messages_per_min` = 120,
+/// enforced in relay `connection.rs::enforce_ws_admission`), shared with the
+/// agent's real chat messages. At 1 frame/s telemetry spends at most 60/min —
+/// half that budget — regardless of how many channels are active. A slower
+/// tick (e.g. 2s → 30/min) would leave more quota headroom for chat at the
+/// price of doubled viewer latency; this constant is the knob.
+const OBSERVER_PUBLISH_TICK: Duration = Duration::from_secs(1);
+
+/// Byte budget for EVERYTHING retained while awaiting a publish slot: the
+/// event FIFO (serialized, post-`fit_observer_event_to_budget` bytes) PLUS
+/// the chunk coalescer's pending buffer (serialized event skeletons + raw
+/// accumulated text). Both stores count against this one cap — a
+/// high-cardinality chunk flood (many distinct coalescer keys) is bounded
+/// exactly like a plain event flood; neither buffer is a bypass around the
+/// other. Lossless-ness is bounded by this budget: each publish slot packs
+/// one ~64KB frame, gathered queue-wide for the front channel, so a single
+/// channel drains at ~64KB/s and 4 MiB buys roughly **64 seconds** of
+/// sustained over-production before the oldest items are dropped WITH
+/// accounting (a warn carrying the dropped-event count). With C channels
+/// producing concurrently the slots round-robin between them, so the
+/// per-channel drain is ~64KB/Cs and the budget shortens accordingly —
+/// still bytes-per-slot, never events-per-slot (see
+/// [`ObserverPublishQueue::next_frame`]). Beyond-budget floods therefore
+/// degrade to designed, visible loss — strictly better than the
+/// pre-batching pacer's silent 90/min drop.
+const OBSERVER_PENDING_QUEUE_MAX_BYTES: usize = 4 * 1024 * 1024;
+
+/// Observer event kind for a batch envelope wrapping multiple events.
+///
+/// The payload is `{"events": [, ...]}` with every inner event
+/// carrying its own `seq`/`timestamp`, so consumers process inner events
+/// exactly as they would unbatched ones. Single pending events are published
+/// unwrapped, so the envelope only appears when there is something to batch.
+const OBSERVER_BATCH_KIND: &str = "batch";
-struct ObserverPublishPacer {
- next_publish: tokio::time::Instant,
- published: VecDeque,
+/// Collects observer events awaiting a publish slot.
+///
+/// Chunk-type events ride the [`ObserverChunkCoalescer`]; everything else is
+/// appended in arrival order, force-flushing pending chunks first — the same
+/// ordering rule the pre-batching publisher enforced, so merged chunk text can
+/// never leapfrog a tool call that arrived mid-stream.
+///
+/// Events wait here as EVENTS, not pre-sealed frames: each publish slot packs
+/// one frame at publish time ([`Self::next_frame`]), so a backlog keeps
+/// compacting into full frames instead of freezing into a frame queue.
+///
+/// The queue is bounded by [`OBSERVER_PENDING_QUEUE_MAX_BYTES`]. When a
+/// sustained flood outruns the one-frame-per-tick drain for longer than the
+/// budget, the OLDEST events are dropped (the viewer wants recent state) with
+/// accounting: a warning carrying the dropped-event count, and
+/// `dropped_events` for tests.
+#[derive(Default)]
+struct ObserverPublishQueue {
+ coalescer: ObserverChunkCoalescer,
+ /// `(serialized_len, source_events, event)`, oldest first. Length is
+ /// captured at enqueue (post-fit) so byte accounting never re-serializes
+ /// on eviction; `source_events` is how many GENERATED observer events the
+ /// entry represents (a merged chunk carries every chunk it absorbed), so
+ /// eviction accounting stays in source units after flush.
+ events: VecDeque<(usize, u64, observer::ObserverEvent)>,
+ pending_bytes: usize,
+ /// SOURCE observer events lost to byte-budget eviction. Counted in
+ /// generated-event units, not retained entries: a coalesced entry that
+ /// merged N chunks accounts for N when evicted. A PUBLISHED merged entry
+ /// delivers all N sources' text in one event, so the invariant is
+ /// `ingested == dropped_events + Σ source_events over published events`.
+ dropped_events: u64,
}
-impl ObserverPublishPacer {
- fn new() -> Self {
- Self {
- // No initial burst: even the first snapshot frame waits for its slot.
- next_publish: tokio::time::Instant::now() + OBSERVER_PUBLISH_INTERVAL,
- published: VecDeque::with_capacity(OBSERVER_PUBLISH_LIMIT_PER_MINUTE),
+impl ObserverPublishQueue {
+ fn ingest(&mut self, event: observer::ObserverEvent) {
+ // ObserverChunkCoalescer::ingest returns immediately-publishable events
+ // (force-flushed pending chunks + non-chunk passthrough, or a pending
+ // set displaced by the 60KB pre-flush); they join the queue in the
+ // order the coalescer emitted them, each carrying the count of source
+ // events it represents.
+ for (source_events, ready) in self.coalescer.ingest(event) {
+ self.enqueue(source_events, ready);
}
+ self.enforce_byte_budget();
}
- async fn wait(&mut self) {
- loop {
- let now = tokio::time::Instant::now();
- while self
- .published
- .front()
- .is_some_and(|sent| now.duration_since(*sent) >= Duration::from_secs(60))
- {
- self.published.pop_front();
+ fn enqueue(&mut self, source_events: u64, mut event: observer::ObserverEvent) {
+ // Pre-trim at enqueue so (a) byte accounting reflects what will ship
+ // and (b) one oversized leaf cannot force every frame it touches into
+ // whole-envelope elision downstream.
+ fit_observer_event_to_budget(&mut event);
+ let bytes = serialized_len(&event);
+ self.pending_bytes += bytes;
+ self.events.push_back((bytes, source_events, event));
+ }
+
+ /// Total bytes retained across BOTH stores — the event FIFO and the
+ /// coalescer's pending chunk buffer. The budget binds this sum; counting
+ /// only the FIFO would let a high-cardinality chunk flood (many distinct
+ /// coalescer keys, nothing ever flushing) grow unbounded outside the cap.
+ fn total_pending_bytes(&self) -> usize {
+ self.pending_bytes + self.coalescer.pending_bytes
+ }
+
+ /// Enforce [`OBSERVER_PENDING_QUEUE_MAX_BYTES`] over the total, dropping
+ /// OLDEST items first with accounting in SOURCE-event units. Global age
+ /// order across the two stores is structural: every enqueue path flushes
+ /// the coalescer first, so every pending coalescer entry is strictly newer
+ /// than every queued event — eviction is queue front, then coalescer
+ /// front. The `> 1` guard never drops the sole remaining item (any single
+ /// fitted event or pre-flush-capped chunk entry is far under the budget).
+ fn enforce_byte_budget(&mut self) {
+ let mut dropped = 0u64;
+ while self.total_pending_bytes() > OBSERVER_PENDING_QUEUE_MAX_BYTES
+ && self.events.len() + self.coalescer.pending.len() > 1
+ {
+ if let Some((bytes, source_events, _)) = self.events.pop_front() {
+ self.pending_bytes -= bytes;
+ dropped += source_events;
+ } else {
+ dropped += self.coalescer.drop_oldest().expect("guard ensures an item");
}
+ }
+ if dropped > 0 {
+ self.dropped_events += dropped;
+ tracing::warn!(
+ dropped,
+ total_dropped = self.dropped_events,
+ pending_bytes = self.total_pending_bytes(),
+ "observer publish queue over byte budget; dropped oldest events"
+ );
+ }
+ }
- let minute_slot = self.published.front().and_then(|sent| {
- (self.published.len() >= OBSERVER_PUBLISH_LIMIT_PER_MINUTE)
- .then_some(*sent + Duration::from_secs(60))
- });
- let publish_at =
- minute_slot.map_or(self.next_publish, |slot| slot.max(self.next_publish));
- if publish_at > now {
- tokio::time::sleep_until(publish_at).await;
- continue;
- }
+ /// True when nothing is waiting anywhere — the event queue AND the
+ /// coalescer's pending chunk buffer.
+ fn is_empty(&self) -> bool {
+ self.events.is_empty() && self.coalescer.pending.is_empty()
+ }
- let published_at = tokio::time::Instant::now();
- self.published.push_back(published_at);
- self.next_publish = published_at + OBSERVER_PUBLISH_INTERVAL;
- return;
+ /// Pack and remove AT MOST ONE publishable frame: the front event's
+ /// channel, gathered queue-wide in FIFO order (packed greedily until
+ /// adding the next event would push the envelope over
+ /// `OBSERVER_MAX_PLAINTEXT_LEN`). Singletons ship unwrapped.
+ ///
+ /// Two invariants bound the gather:
+ /// - A frame never mixes channels (the desktop archive indexes a frame
+ /// under its decrypted top-level `channelId`), and events keep their
+ /// FIFO order *within* each channel. Cross-channel frame order MAY
+ /// differ from arrival order — the desktop tolerates that everywhere:
+ /// the transcript store sorts + rebuilds on out-of-order arrival, the
+ /// archive is per-channel by construction, and the turn store's
+ /// watermark is keyed per (agent, channel).
+ /// - A NULL-channel event is a BARRIER nothing gathers across: null-scope
+ /// events (`agent_panic`-class) can causally couple to any channel, so
+ /// their relative order against every channel is preserved exactly.
+ /// Null-channel events themselves ship only as their contiguous front
+ /// run.
+ ///
+ /// Gathering queue-wide (not just the front run) is what keeps the drain
+ /// rate in BYTES per slot rather than front-run-length events per slot:
+ /// with round-robin producers (channel A, B, A, B, ...) a front-run
+ /// packer degrades to ~1 event per slot regardless of size, silently
+ /// growing latency without ever tripping the byte budget.
+ ///
+ /// Pending coalesced chunks are flushed into the queue first, so a
+ /// publish slot never leaves merged chunk text stranded behind the tick.
+ fn next_frame(&mut self) -> Option {
+ for (source_events, ready) in self.coalescer.flush() {
+ self.enqueue(source_events, ready);
}
+ let channel = self.events.front()?.2.channel_id.clone();
+
+ let mut picked: Vec = Vec::new();
+ let mut kept: VecDeque<(usize, u64, observer::ObserverEvent)> =
+ VecDeque::with_capacity(self.events.len());
+ let mut gathering = true;
+ while let Some((bytes, source_events, event)) = self.events.pop_front() {
+ if gathering && event.channel_id == channel {
+ picked.push(event);
+ if picked.len() > 1
+ && serialized_len(&batch_envelope(&picked)) > OBSERVER_MAX_PLAINTEXT_LEN
+ {
+ // Frame full: the overflow event stays queued and leads
+ // its channel's next slot.
+ let event = picked.pop().expect("len > 1");
+ kept.push_back((bytes, source_events, event));
+ gathering = false;
+ } else {
+ self.pending_bytes -= bytes;
+ }
+ } else {
+ if gathering && (channel.is_none() || event.channel_id.is_none()) {
+ // Null-channel barrier (or, for a null-channel frame, the
+ // end of its contiguous front run): stop gathering.
+ gathering = false;
+ }
+ kept.push_back((bytes, source_events, event));
+ }
+ }
+ self.events = kept;
+ Some(seal_batch(picked))
+ }
+}
+
+/// A single event ships unwrapped; two or more get the batch envelope.
+fn seal_batch(mut events: Vec) -> observer::ObserverEvent {
+ if events.len() == 1 {
+ return events.pop().expect("len == 1");
+ }
+ batch_envelope(&events)
+}
+
+/// Build the batch envelope for a set of same-channel events.
+///
+/// Envelope metadata mirrors the LAST inner event — the same convention the
+/// chunk coalescer uses for merged chunks — so `(timestamp, seq)` ordering and
+/// the desktop's latest-live-session tracking see the newest state.
+fn batch_envelope(events: &[observer::ObserverEvent]) -> observer::ObserverEvent {
+ let last = events
+ .last()
+ .expect("batch envelope needs at least 1 event");
+ observer::ObserverEvent {
+ seq: last.seq,
+ timestamp: last.timestamp.clone(),
+ kind: OBSERVER_BATCH_KIND.to_string(),
+ agent_index: last.agent_index,
+ channel_id: last.channel_id.clone(),
+ session_id: last.session_id.clone(),
+ turn_id: last.turn_id.clone(),
+ started_at: last.started_at.clone(),
+ payload: serde_json::json!({
+ "events": serde_json::to_value(events).unwrap_or_default(),
+ }),
}
}
@@ -500,29 +692,26 @@ async fn run_relay_observer_publisher(
owner_pubkey_hex: String,
owner_pubkey: PublicKey,
) {
- let mut coalescer = ObserverChunkCoalescer::default();
- let mut pacer = ObserverPublishPacer::new();
+ let mut queue = ObserverPublishQueue::default();
let max_snapshot_seq = snapshot.iter().map(|event| event.seq).max().unwrap_or(0);
for event in snapshot {
- for event in coalescer.ingest(event) {
- publish_relay_observer_event(
- &publisher,
- &keys,
- &agent_pubkey_hex,
- &owner_pubkey_hex,
- &owner_pubkey,
- &mut pacer,
- event,
- )
- .await;
- }
+ queue.ingest(event);
}
- let mut flush_interval = tokio::time::interval(std::time::Duration::from_millis(500));
- flush_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ // Global pacer: AT MOST ONE relay frame per tick, no matter how many
+ // channels are active or how large the backlog is. `interval_at` starts
+ // the first tick a full period out, so a pre-loaded snapshot (up to the
+ // 1,000-event replay buffer on reconnect) cannot burst at t=0 — the old
+ // pacer's explicit "no initial burst" property, restored.
+ let mut publish_tick = tokio::time::interval_at(
+ tokio::time::Instant::now() + OBSERVER_PUBLISH_TICK,
+ OBSERVER_PUBLISH_TICK,
+ );
+ publish_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ let mut closed = false;
loop {
tokio::select! {
- result = rx.recv() => {
+ result = rx.recv(), if !closed => {
match result {
Ok(event) => {
// Skip live events already delivered via the snapshot
@@ -530,41 +719,30 @@ async fn run_relay_observer_publisher(
if event.seq <= max_snapshot_seq {
continue;
}
- for event in coalescer.ingest(event) {
- publish_relay_observer_event(
- &publisher, &keys, &agent_pubkey_hex,
- &owner_pubkey_hex, &owner_pubkey, &mut pacer, event,
- ).await;
- }
+ queue.ingest(event);
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
- for event in coalescer.flush() {
- publish_relay_observer_event(
- &publisher, &keys, &agent_pubkey_hex,
- &owner_pubkey_hex, &owner_pubkey, &mut pacer, event,
- ).await;
- }
tracing::warn!(dropped = count, "relay observer publisher lagged");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
- for event in coalescer.flush() {
- publish_relay_observer_event(
- &publisher, &keys, &agent_pubkey_hex,
- &owner_pubkey_hex, &owner_pubkey, &mut pacer, event,
- ).await;
- }
- break;
+ // Producer gone: stop selecting on the receiver and let
+ // the tick arm drain what remains — still one frame per
+ // tick. An unpaced final drain would be a burst bypass
+ // around everything the pacer exists to prevent.
+ closed = true;
}
}
}
- _ = flush_interval.tick() => {
- // Periodic flush ensures live streaming even during continuous chunk delivery.
- for event in coalescer.flush() {
+ _ = publish_tick.tick() => {
+ if let Some(frame) = queue.next_frame() {
publish_relay_observer_event(
&publisher, &keys, &agent_pubkey_hex,
- &owner_pubkey_hex, &owner_pubkey, &mut pacer, event,
+ &owner_pubkey_hex, &owner_pubkey, frame,
).await;
}
+ if closed && queue.is_empty() {
+ break;
+ }
}
}
}
@@ -573,12 +751,25 @@ async fn run_relay_observer_publisher(
#[derive(Default)]
struct ObserverChunkCoalescer {
pending: Vec,
+ /// Approximate serialized bytes retained in `pending` (each entry's
+ /// serialized skeleton at creation plus appended chunk text). Counted
+ /// against [`OBSERVER_PENDING_QUEUE_MAX_BYTES`] by the owning
+ /// [`ObserverPublishQueue`] so this buffer can never grow outside the
+ /// queue's byte budget (a distinct-key chunk flood parks everything here
+ /// and nothing would otherwise bound it).
+ pending_bytes: usize,
}
struct PendingObserverChunk {
key: ObserverChunkKey,
event: observer::ObserverEvent,
text: String,
+ /// Bytes this entry contributes to `pending_bytes`.
+ bytes: usize,
+ /// GENERATED observer events merged into this entry (1 at creation, +1
+ /// per absorbed chunk). Evicting the entry loses this many source events,
+ /// so drop accounting must charge this count, not 1.
+ source_events: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -599,10 +790,13 @@ struct ObserverChunkKey {
const OBSERVER_CHUNK_MAX_TEXT_BYTES: usize = 60_000;
impl ObserverChunkCoalescer {
- fn ingest(&mut self, event: observer::ObserverEvent) -> Vec {
+ /// Returns immediately-publishable events, each paired with the number of
+ /// SOURCE observer events it represents (merged chunks carry the count of
+ /// every chunk they absorbed; passthrough events are always 1).
+ fn ingest(&mut self, event: observer::ObserverEvent) -> Vec<(u64, observer::ObserverEvent)> {
let Some((key, text)) = observer_chunk_key_and_text(&event) else {
let mut events = self.flush();
- events.push(event);
+ events.push((1, event));
return events;
};
@@ -611,25 +805,64 @@ impl ObserverChunkCoalescer {
if pending.text.len() + text.len() >= OBSERVER_CHUNK_MAX_TEXT_BYTES {
let events = self.flush();
// Start a new pending entry with the current chunk.
- self.pending.push(PendingObserverChunk { key, event, text });
+ self.push_pending(key, event, text);
return events;
}
pending.text.push_str(&text);
+ pending.bytes += text.len();
+ pending.source_events += 1;
+ self.pending_bytes += text.len();
pending.event.seq = event.seq;
pending.event.timestamp = event.timestamp;
return Vec::new();
}
- self.pending.push(PendingObserverChunk { key, event, text });
+ self.push_pending(key, event, text);
Vec::new()
}
- fn flush(&mut self) -> Vec {
+ fn push_pending(
+ &mut self,
+ key: ObserverChunkKey,
+ event: observer::ObserverEvent,
+ text: String,
+ ) {
+ // The entry RETAINS the first chunk's text twice until flush: once
+ // inside the serialized skeleton (`event.payload` still carries it)
+ // and once as the extracted `text` copy that appends grow. Both are
+ // real memory, so both count — charging only `serialized_len` lets a
+ // high-cardinality flood retain up to 2x the byte budget (each entry
+ // undercounts by exactly its first chunk's length).
+ let bytes = serialized_len(&event) + text.len();
+ self.pending_bytes += bytes;
+ self.pending.push(PendingObserverChunk {
+ key,
+ event,
+ text,
+ bytes,
+ source_events: 1,
+ });
+ }
+
+ /// Evict the OLDEST pending entry for byte-budget enforcement. Returns
+ /// the number of SOURCE events the entry represented (its merged chunk
+ /// count), or `None` when there is nothing to drop.
+ fn drop_oldest(&mut self) -> Option {
+ if self.pending.is_empty() {
+ return None;
+ }
+ let removed = self.pending.remove(0);
+ self.pending_bytes -= removed.bytes;
+ Some(removed.source_events)
+ }
+
+ fn flush(&mut self) -> Vec<(u64, observer::ObserverEvent)> {
+ self.pending_bytes = 0;
self.pending
.drain(..)
.map(|mut pending| {
set_observer_chunk_text(&mut pending.event.payload, pending.text);
- pending.event
+ (pending.source_events, pending.event)
})
.collect()
}
@@ -848,10 +1081,8 @@ async fn publish_relay_observer_event(
agent_pubkey_hex: &str,
owner_pubkey_hex: &str,
owner_pubkey: &PublicKey,
- pacer: &mut ObserverPublishPacer,
mut event: observer::ObserverEvent,
) {
- pacer.wait().await;
// Trim oversized frames to fit the plaintext cap rather than letting
// encrypt_observer_payload reject and drop them whole (silent telemetry loss).
fit_observer_event_to_budget(&mut event);
@@ -1283,6 +1514,59 @@ impl Drop for RespawnGuard {
// sync entry point — `std::env::set_var` is only safe before tokio spawns
// worker threads (Rust 2024 edition safety requirement).
+fn inactivity_expired(
+ last_activity: tokio::time::Instant,
+ now: tokio::time::Instant,
+ bound: Duration,
+ turn_in_flight: bool,
+) -> bool {
+ !bound.is_zero() && !turn_in_flight && now.duration_since(last_activity) >= bound
+}
+
+#[cfg(test)]
+mod inactivity_tests {
+ use super::*;
+
+ #[test]
+ fn zero_disables_expiry_and_in_flight_turns_defer_it() {
+ let started = tokio::time::Instant::now();
+ let after_bound = started + Duration::from_secs(61);
+
+ assert!(!inactivity_expired(
+ started,
+ after_bound,
+ Duration::ZERO,
+ false
+ ));
+ assert!(!inactivity_expired(
+ started,
+ after_bound,
+ Duration::from_secs(60),
+ true
+ ));
+ assert!(inactivity_expired(
+ started,
+ after_bound,
+ Duration::from_secs(60),
+ false
+ ));
+ }
+
+ #[test]
+ fn dispatched_activity_restarts_the_inactivity_bound() {
+ let started = tokio::time::Instant::now();
+ let dispatched = started + Duration::from_secs(50);
+ let checked = started + Duration::from_secs(61);
+
+ assert!(!inactivity_expired(
+ dispatched,
+ checked,
+ Duration::from_secs(60),
+ false
+ ));
+ }
+}
+
pub fn run() -> Result<()> {
config::propagate_legacy_env_vars();
tokio_main()
@@ -1655,6 +1939,21 @@ async fn tokio_main() -> Result<()> {
let mut typing_channels: HashMap = HashMap::new();
let mut presence_task: Option> = None;
+ // Independent of pool readiness: a never-mentioned lazy agent must still
+ // self-terminate. The watch interval is capped so small configured bounds
+ // remain reasonably precise without waking long-lived agents frequently.
+ let inactivity_bound = Duration::from_secs(config.exit_after_inactivity_secs);
+ let mut last_activity = tokio::time::Instant::now();
+ let mut inactivity_reaper = if inactivity_bound.is_zero() {
+ None
+ } else {
+ let interval = inactivity_bound.min(Duration::from_secs(30));
+ Some(tokio::time::interval_at(
+ tokio::time::Instant::now() + interval,
+ interval,
+ ))
+ };
+
// Runs at the TOP of every loop iteration via Instant check — cannot be
// starved by the biased select. Slot refill spawns background tasks so
// spawn_and_init never blocks the main loop.
@@ -1828,7 +2127,9 @@ async fn tokio_main() -> Result<()> {
// called on relay events or pool results, neither of which
// arrive when the channel is silent.
if queue.has_flushable_work() {
- for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) {
+ for (channel_id, thread_tags) in
+ dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity)
+ {
typing_channels.insert(channel_id, thread_tags);
}
}
@@ -1864,7 +2165,9 @@ async fn tokio_main() -> Result<()> {
// this, batches requeued during crash recovery sit idle until the
// next relay event arrives — which can be minutes on quiet channels.
if respawn_collected {
- for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) {
+ for (channel_id, thread_tags) in
+ dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity)
+ {
typing_channels.insert(channel_id, thread_tags);
}
}
@@ -2275,7 +2578,7 @@ async fn tokio_main() -> Result<()> {
}
if pool_ready {
for (channel_id, thread_tags) in
- dispatch_pending(&mut pool, &mut queue, &ctx)
+ dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity)
{
typing_channels.insert(channel_id, thread_tags);
}
@@ -2292,6 +2595,27 @@ async fn tokio_main() -> Result<()> {
}
None
}
+ _ = async {
+ match inactivity_reaper.as_mut() {
+ Some(timer) => timer.tick().await,
+ None => std::future::pending().await,
+ }
+ } => {
+ let _ = result_rx;
+ if inactivity_expired(
+ last_activity,
+ tokio::time::Instant::now(),
+ inactivity_bound,
+ queue.has_in_flight() || heartbeat_in_flight,
+ ) {
+ tracing::info!(
+ inactivity_seconds = config.exit_after_inactivity_secs,
+ "inactivity bound reached — exiting gracefully"
+ );
+ let _ = shutdown_tx.send(());
+ }
+ None
+ }
_ = async {
match heartbeat.as_mut() {
Some(hb) => hb.tick().await,
@@ -2304,7 +2628,7 @@ async fn tokio_main() -> Result<()> {
} else if queue.has_flushable_work() {
tracing::debug!("heartbeat_skipped_events");
for (channel_id, thread_tags) in
- dispatch_pending(&mut pool, &mut queue, &ctx)
+ dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity)
{
typing_channels.insert(channel_id, thread_tags);
}
@@ -2402,7 +2726,9 @@ async fn tokio_main() -> Result<()> {
{
break;
}
- for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) {
+ for (channel_id, thread_tags) in
+ dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity)
+ {
typing_channels.insert(channel_id, thread_tags);
}
}
@@ -2425,7 +2751,9 @@ async fn tokio_main() -> Result<()> {
tracing::error!("all agents dead — exiting");
break;
}
- for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) {
+ for (channel_id, thread_tags) in
+ dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity)
+ {
typing_channels.insert(channel_id, thread_tags);
}
}
@@ -2567,7 +2895,9 @@ async fn tokio_main() -> Result<()> {
// tear down the in-flight task; on its completion the
// queue drains. We still try here in case the in-flight
// task has already returned.
- for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) {
+ for (channel_id, thread_tags) in
+ dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity)
+ {
typing_channels.insert(channel_id, thread_tags);
}
}
@@ -2594,7 +2924,7 @@ async fn tokio_main() -> Result<()> {
None,
);
for (channel_id, thread_tags) in
- dispatch_pending(&mut pool, &mut queue, &ctx)
+ dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity)
{
typing_channels.insert(channel_id, thread_tags);
}
@@ -2928,6 +3258,7 @@ fn dispatch_pending(
pool: &mut AgentPool,
queue: &mut EventQueue,
ctx: &Arc,
+ last_activity: &mut tokio::time::Instant,
) -> Vec<(Uuid, ThreadTags)> {
let mut dispatched_channels = Vec::new();
loop {
@@ -3007,6 +3338,7 @@ fn dispatch_pending(
},
);
dispatched_channels.push((channel_id, typing_scope));
+ *last_activity = tokio::time::Instant::now();
}
tracing::debug!(
dispatched = dispatched_channels.len(),
@@ -4942,12 +5274,21 @@ mod observer_snapshot_race_tests {
// The run loop has exited, dropping the publisher; drain the forwarded
// events until the channel closes (deterministic — no try_recv race
- // with the test_pair forwarding task).
+ // with the test_pair forwarding task). With per-tick batching the three
+ // events arrive inside batch envelopes (or unwrapped when a drain held
+ // exactly one event); unwrap both shapes.
let mut markers = Vec::new();
while let Some(event) = published_rx.recv().await {
let payload: serde_json::Value =
decrypt_observer_payload(&owner_keys, &event).expect("decrypt published frame");
- markers.push(payload["payload"]["marker"].as_str().unwrap().to_string());
+ match payload["payload"]["events"].as_array() {
+ Some(inner) => markers.extend(
+ inner
+ .iter()
+ .map(|e| e["payload"]["marker"].as_str().unwrap().to_string()),
+ ),
+ None => markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()),
+ }
}
assert_eq!(
markers,
@@ -4958,36 +5299,865 @@ mod observer_snapshot_race_tests {
}
#[cfg(test)]
-mod observer_publish_pacer_tests {
+mod observer_publish_queue_tests {
use super::*;
+ fn event(seq: u64, kind: &str, channel: Option<&str>) -> observer::ObserverEvent {
+ observer::ObserverEvent {
+ seq,
+ timestamp: format!("2026-04-29T04:00:{:02}Z", seq.min(59)),
+ kind: kind.to_string(),
+ agent_index: Some(0),
+ channel_id: channel.map(ToOwned::to_owned),
+ session_id: Some("session-1".to_string()),
+ turn_id: Some("turn-1".to_string()),
+ started_at: None,
+ payload: serde_json::json!({ "seq": seq }),
+ }
+ }
+
+ fn queue_of(events: Vec) -> ObserverPublishQueue {
+ let mut queue = ObserverPublishQueue::default();
+ for event in events {
+ queue.ingest(event);
+ }
+ queue
+ }
+
+ /// Collect every frame the queue will produce, one publish slot at a time.
+ fn drain_frames(queue: &mut ObserverPublishQueue) -> Vec {
+ let mut frames = Vec::new();
+ while !queue.is_empty() {
+ frames.push(queue.next_frame().expect("queue not empty"));
+ }
+ frames
+ }
+
+ /// Inner seqs of a frame, whether it is an envelope or an unwrapped
+ /// singleton.
+ fn frame_seqs(frame: &observer::ObserverEvent) -> Vec {
+ match frame.payload.get("events").and_then(|v| v.as_array()) {
+ Some(inner) => inner.iter().map(|e| e["seq"].as_u64().unwrap()).collect(),
+ None => vec![frame.seq],
+ }
+ }
+
+ /// Retained bytes computed by WALKING the entries, independently of the
+ /// queue's own accumulator. Cap regressions must assert on this, not on
+ /// `total_pending_bytes()` — asserting the counter against itself passed
+ /// while the process retained ~2x the budget (Sami/Max round 3: each
+ /// pending coalescer entry holds the first chunk's text twice, in the
+ /// serialized skeleton AND the extracted `text` copy).
+ fn walked_retained_bytes(queue: &ObserverPublishQueue) -> usize {
+ let fifo: usize = queue
+ .events
+ .iter()
+ .map(|(_, _, event)| serialized_len(event))
+ .sum();
+ let coalescer: usize = queue
+ .coalescer
+ .pending
+ .iter()
+ .map(|pending| serialized_len(&pending.event) + pending.text.len())
+ .sum();
+ fifo + coalescer
+ }
+
+ /// The walker above is itself an instrument, and every cap test asks it
+ /// only for `<= CAP` — a blinded walker (missing an arm, or returning 0)
+ /// would satisfy all of them while hiding exactly the 2x overshoot it was
+ /// added to catch (Sami round 5, M17-M20). Pin it two-sided: it must SEE
+ /// the double retention, and it must agree with the accumulator EXACTLY
+ /// while both stores are non-empty — neither may drift.
+ #[test]
+ fn walked_retained_bytes_agrees_with_the_accumulator_exactly() {
+ fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent {
+ let mut e = event(seq, "acp_read", Some("chan-a"));
+ e.payload = serde_json::json!({
+ "jsonrpc": "2.0",
+ "method": "session/update",
+ "params": {
+ "sessionId": "session-1",
+ "update": {
+ "sessionUpdate": "agent_message_chunk",
+ "messageId": message_id,
+ "content": { "type": "text", "text": text },
+ },
+ },
+ });
+ e
+ }
+
+ let text = "w".repeat(7_000);
+ let mut queue = ObserverPublishQueue::default();
+ // One pending chunk: its text lives in the serialized skeleton AND
+ // the extracted copy, so a walker blind to either arm reads short.
+ queue.ingest(chunk(1, "message-a", &text));
+ assert!(
+ walked_retained_bytes(&queue) >= 2 * text.len(),
+ "the walker must SEE the first chunk's text twice \
+ (skeleton + extracted copy), got {}",
+ walked_retained_bytes(&queue)
+ );
+
+ // Populate BOTH stores: the non-chunk event flushes message-a into
+ // the FIFO and queues itself; fresh pending keys (plus a same-key
+ // append) rebuild the coalescer side.
+ queue.ingest(event(2, "tool_call", Some("chan-a")));
+ queue.ingest(chunk(3, "message-b", &text));
+ queue.ingest(chunk(4, "message-b", &text));
+ queue.ingest(chunk(5, "message-c", &text));
+ assert!(
+ !queue.events.is_empty() && !queue.coalescer.pending.is_empty(),
+ "both arms must be non-empty for the agreement check to bind"
+ );
+ assert_eq!(
+ queue.total_pending_bytes(),
+ walked_retained_bytes(&queue),
+ "accumulator and entry-walk must agree exactly: neither may drift"
+ );
+ }
+
+ /// Two or more pending events for one channel ship as a single batch
+ /// envelope whose payload carries every inner event in arrival order.
+ #[test]
+ fn multiple_events_ship_as_one_envelope_in_order() {
+ let mut queue = queue_of(vec![
+ event(1, "turn_started", Some("chan-a")),
+ event(2, "acp_read", Some("chan-a")),
+ event(3, "acp_write", Some("chan-a")),
+ ]);
+
+ let frame = queue.next_frame().expect("one frame");
+ assert!(queue.is_empty(), "one channel, one publish slot");
+ assert_eq!(frame.kind, OBSERVER_BATCH_KIND);
+ assert_eq!(frame.seq, 3, "envelope mirrors the last inner event");
+ assert_eq!(frame_seqs(&frame), [1, 2, 3], "arrival order preserved");
+ let inner = frame.payload["events"].as_array().expect("events array");
+ assert_eq!(inner[1]["kind"], "acp_read", "inner events keep their kind");
+ }
+
+ /// A single pending event is published unwrapped — no envelope, so
+ /// consumers that predate batching still understand quiet periods.
+ #[test]
+ fn a_single_event_stays_unwrapped() {
+ let mut queue = queue_of(vec![event(7, "turn_started", Some("chan-a"))]);
+ let frame = queue.next_frame().expect("one frame");
+ assert!(queue.is_empty());
+ assert_eq!(frame.kind, "turn_started");
+ assert_eq!(frame.seq, 7);
+ }
+
+ /// An empty queue yields no frame — a tick with nothing pending must not
+ /// publish anything.
+ #[test]
+ fn empty_queue_yields_no_frame() {
+ let mut queue = ObserverPublishQueue::default();
+ assert!(queue.next_frame().is_none());
+ assert!(queue.is_empty());
+ }
+
+ /// Frames never mix channels, and each channel's events keep their FIFO
+ /// order. Gathering is QUEUE-WIDE: the front event's channel collects its
+ /// events from anywhere in the queue (that is what keeps the drain rate
+ /// in bytes per slot under interleaving), so cross-channel frame order
+ /// MAY differ from arrival order — but a null-channel event is a barrier
+ /// nothing gathers across.
+ #[test]
+ fn frames_never_mix_channels_and_gather_queue_wide() {
+ let mut queue = queue_of(vec![
+ event(1, "acp_read", Some("chan-a")),
+ event(2, "acp_write", Some("chan-a")),
+ event(3, "acp_read", Some("chan-b")),
+ event(4, "acp_read", Some("chan-a")),
+ event(5, "acp_read", None),
+ ]);
+
+ let frames = drain_frames(&mut queue);
+ assert_eq!(
+ frames.len(),
+ 3,
+ "gathered: [1,2,4]@a, [3]@b, [5]@None — one frame each"
+ );
+ for frame in &frames {
+ let channels: HashSet> = match frame.payload.get("events") {
+ Some(serde_json::Value::Array(inner)) => inner
+ .iter()
+ .map(|e| e["channelId"].as_str().map(ToOwned::to_owned))
+ .collect(),
+ _ => std::iter::once(frame.channel_id.clone()).collect(),
+ };
+ assert_eq!(channels.len(), 1, "a frame never mixes channels");
+ }
+ assert_eq!(
+ frame_seqs(&frames[0]),
+ [1, 2, 4],
+ "chan-a gathers queue-wide, FIFO within the channel"
+ );
+ assert_eq!(frames[0].channel_id.as_deref(), Some("chan-a"));
+ assert_eq!(frames[1].kind, "acp_read", "singleton stays unwrapped");
+ assert_eq!(frames[1].channel_id.as_deref(), Some("chan-b"));
+ assert_eq!(frames[2].channel_id, None);
+ }
+
+ /// A NULL-channel event is a barrier: channel events queued BEHIND it
+ /// must not gather into a frame ahead of it, so causally-global events
+ /// (`agent_panic`-class) keep their exact order against every channel.
+ /// The null event itself ships only its contiguous front run.
+ #[test]
+ fn null_channel_events_are_gather_barriers() {
+ let mut queue = queue_of(vec![
+ event(1, "acp_read", Some("chan-a")),
+ event(2, "acp_read", Some("chan-b")),
+ event(3, "agent_panic", None),
+ event(4, "acp_write", Some("chan-a")),
+ ]);
+
+ let frames = drain_frames(&mut queue);
+ let published: Vec> = frames.iter().map(frame_seqs).collect();
+ assert_eq!(
+ published,
+ [vec![1], vec![2], vec![3], vec![4]],
+ "seq 4 must not gather past the null barrier into frame 1"
+ );
+ }
+
+ /// The drain-rate regression Sami measured: with two channels strictly
+ /// alternating, a front-run packer degrades to ONE event per slot
+ /// (~275 B/s regardless of the 64KB frame budget). Queue-wide gathering
+ /// must drain an interleaved backlog in ~ceil(events / per-frame-fit)
+ /// slots per channel, not one slot per event.
+ #[test]
+ fn interleaved_channels_drain_at_bytes_per_slot_not_events_per_slot() {
+ let mut events = Vec::new();
+ for i in 0..100u64 {
+ events.push(event(2 * i + 1, "acp_read", Some("chan-a")));
+ events.push(event(2 * i + 2, "acp_read", Some("chan-b")));
+ }
+ let mut queue = queue_of(events);
+
+ let frames = drain_frames(&mut queue);
+ assert!(
+ frames.len() <= 4,
+ "200 tiny alternating events must gather into a few full frames, \
+ got {} (front-run packing would need 200 slots)",
+ frames.len()
+ );
+ for frame in &frames {
+ assert!(serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN);
+ }
+ // Within each channel, FIFO order survives the gather.
+ let mut seqs_a = Vec::new();
+ let mut seqs_b = Vec::new();
+ for frame in &frames {
+ match frame.channel_id.as_deref() {
+ Some("chan-a") => seqs_a.extend(frame_seqs(frame)),
+ Some("chan-b") => seqs_b.extend(frame_seqs(frame)),
+ other => panic!("unexpected channel {other:?}"),
+ }
+ }
+ assert!(seqs_a.windows(2).all(|w| w[0] < w[1]), "chan-a FIFO");
+ assert!(seqs_b.windows(2).all(|w| w[0] < w[1]), "chan-b FIFO");
+ assert_eq!(seqs_a.len() + seqs_b.len(), 200, "nothing lost");
+ }
+
+ /// A same-channel backlog that cannot fit one 64KB frame splits across
+ /// SUCCESSIVE publish slots — never multiple frames from one slot — with
+ /// every frame under the cap and no event lost or reordered.
+ #[test]
+ fn oversized_backlogs_split_across_publish_slots_under_the_cap() {
+ let big_text = "x".repeat(30_000);
+ let mut queue = queue_of(
+ (1..=6)
+ .map(|seq| {
+ let mut e = event(seq, "acp_read", Some("chan-a"));
+ e.payload = serde_json::json!({ "seq": seq, "text": big_text });
+ e
+ })
+ .collect(),
+ );
+
+ let frames = drain_frames(&mut queue);
+ assert!(
+ frames.len() > 1,
+ "six 30KB events cannot fit one 64KB frame"
+ );
+ let mut seen = Vec::new();
+ for frame in &frames {
+ assert!(
+ serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN,
+ "every emitted frame must fit the plaintext cap"
+ );
+ seen.extend(frame_seqs(frame));
+ }
+ assert_eq!(
+ seen,
+ [1, 2, 3, 4, 5, 6],
+ "no event lost or reordered by splitting"
+ );
+ }
+
+ /// The queue preserves the coalescer's ordering rule: a non-chunk event
+ /// force-flushes pending chunk text ahead of itself, so merged chunks can
+ /// never leapfrog a tool call that arrived after them.
+ #[test]
+ fn non_chunk_events_flush_pending_chunks_ahead_of_themselves() {
+ fn chunk(seq: u64, text: &str) -> observer::ObserverEvent {
+ let mut e = event(seq, "acp_read", Some("chan-a"));
+ e.payload = serde_json::json!({
+ "params": { "update": {
+ "sessionUpdate": "agent_message_chunk",
+ "messageId": "m1",
+ "content": { "text": text },
+ }}
+ });
+ e
+ }
+
+ let mut queue = ObserverPublishQueue::default();
+ queue.ingest(chunk(1, "hello "));
+ queue.ingest(chunk(2, "world"));
+ queue.ingest(event(3, "tool_call", Some("chan-a")));
+
+ let frame = queue.next_frame().expect("one frame");
+ assert!(queue.is_empty());
+ let inner = frame.payload["events"].as_array().expect("batch of 2");
+ assert_eq!(inner.len(), 2, "two chunks coalesce into one event");
+ assert_eq!(
+ inner[0]["payload"]["params"]["update"]["content"]["text"], "hello world",
+ "chunk text merged before the tool call"
+ );
+ assert_eq!(inner[1]["kind"], "tool_call");
+ assert!(inner[0]["seq"].as_u64() < inner[1]["seq"].as_u64());
+ }
+
+ /// Chunks still pending inside the coalescer (no non-chunk flushed them)
+ /// are picked up by the publish slot itself, not stranded.
+ #[test]
+ fn a_publish_slot_flushes_pending_coalesced_chunks() {
+ let mut e = event(1, "acp_read", Some("chan-a"));
+ e.payload = serde_json::json!({
+ "params": { "update": {
+ "sessionUpdate": "agent_message_chunk",
+ "messageId": "m1",
+ "content": { "text": "buffered" },
+ }}
+ });
+ let mut queue = ObserverPublishQueue::default();
+ queue.ingest(e);
+ assert!(!queue.is_empty(), "pending chunk counts as queued work");
+
+ let frame = queue.next_frame().expect("chunk must ship");
+ assert!(queue.is_empty());
+ assert_eq!(
+ frame.payload["params"]["update"]["content"]["text"],
+ "buffered"
+ );
+ }
+
+ /// Sami's ceiling assertion: when sustained input outruns the one-frame
+ /// drain budget for longer than the queue's byte budget, the OLDEST events
+ /// drop with accounting — never silently — and everything that survives
+ /// publishes in order with nothing else lost.
+ #[test]
+ fn over_budget_floods_drop_oldest_with_accounting() {
+ let big_text = "y".repeat(10_000);
+ let total = 500usize; // ~5MB of ~10KB events > 4MiB budget
+ let mut queue = ObserverPublishQueue::default();
+ for seq in 1..=total as u64 {
+ let mut e = event(seq, "acp_read", Some("chan-a"));
+ e.payload = serde_json::json!({ "seq": seq, "text": big_text });
+ queue.ingest(e);
+ }
+
+ assert!(
+ queue.dropped_events > 0,
+ "a 5MB backlog must overflow the 4MiB budget"
+ );
+ assert!(
+ walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES,
+ "eviction must restore the byte budget (entry-walked), got {}",
+ walked_retained_bytes(&queue)
+ );
+
+ let frames = drain_frames(&mut queue);
+ let published: Vec = frames.iter().flat_map(frame_seqs).collect();
+ let expected: Vec = (queue.dropped_events + 1..=total as u64).collect();
+ assert_eq!(
+ published, expected,
+ "exactly the oldest `dropped_events` events are missing; the rest \
+ publish in order"
+ );
+ assert_eq!(
+ published.len() as u64 + queue.dropped_events,
+ total as u64,
+ "accounting: published + dropped == ingested"
+ );
+ }
+
+ /// Max's coalescer-bypass regression: a flood of chunks with DISTINCT
+ /// messageIds never flushes on its own, so every chunk sits in the
+ /// coalescer's pending buffer. TRUE retained bytes — walked from the
+ /// entries, never the queue's own accumulator — MUST respect the byte
+ /// budget with event-level drop accounting. Pre-fix this retained ~25MB
+ /// against the 4 MiB cap with `pending_bytes == 0` and zero drops; the
+ /// round-3 refinement (Sami/Max) caught the accumulator itself reading
+ /// under cap while true retention was 1.99x over.
+ #[test]
+ fn distinct_key_chunk_floods_are_bounded_by_the_byte_budget() {
+ let big_text = "z".repeat(50_000);
+ let total = 500u64; // ~25MB pending chunk text vs a 4MiB budget
+ let mut queue = ObserverPublishQueue::default();
+ for seq in 1..=total {
+ let mut e = event(seq, "acp_read", Some("chan-a"));
+ e.payload = serde_json::json!({
+ "jsonrpc": "2.0",
+ "method": "session/update",
+ "params": {
+ "sessionId": "session-1",
+ "update": {
+ "sessionUpdate": "agent_message_chunk",
+ "messageId": format!("message-{seq}"),
+ "content": { "type": "text", "text": big_text },
+ },
+ },
+ });
+ queue.ingest(e);
+ }
+
+ let walked = walked_retained_bytes(&queue);
+ assert!(
+ walked <= OBSERVER_PENDING_QUEUE_MAX_BYTES,
+ "TRUE retained bytes (walked from entries) must respect the cap, \
+ got {walked}"
+ );
+ assert!(
+ queue.total_pending_bytes() >= walked,
+ "the accumulator must never under-count true retention \
+ (accumulator {} < walked {walked})",
+ queue.total_pending_bytes()
+ );
+ assert!(
+ queue.dropped_events > 0,
+ "a ~25MB distinct-key chunk flood must record drops"
+ );
+ // Event-level accounting: everything that survives publishes, and
+ // survivors + dropped == ingested.
+ let frames = drain_frames(&mut queue);
+ let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum();
+ assert_eq!(
+ survived + queue.dropped_events,
+ total,
+ "accounting: published + dropped == ingested"
+ );
+ // The survivors are the NEWEST events (drop-oldest).
+ let last_frame_seqs = frame_seqs(frames.last().expect("frames"));
+ assert_eq!(*last_frame_seqs.last().expect("seqs"), total);
+ }
+
+ /// Max's merged-chunk accounting regression: one coalescer entry can
+ /// represent MANY generated observer events (same-messageId chunks merge
+ /// in place), so evicting it must charge every merged source event to
+ /// `dropped_events`, not 1 per retained entry. Pre-fix, evicting an entry
+ /// that merged 50 chunks recorded `dropped_events == 1` and 49 generated
+ /// events vanished from the accounting.
+ #[test]
+ fn evicting_a_merged_chunk_entry_accounts_every_source_event() {
+ fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent {
+ let mut e = event(seq, "acp_read", Some("chan-a"));
+ e.payload = serde_json::json!({
+ "jsonrpc": "2.0",
+ "method": "session/update",
+ "params": {
+ "sessionId": "session-1",
+ "update": {
+ "sessionUpdate": "agent_message_chunk",
+ "messageId": message_id,
+ "content": { "type": "text", "text": text },
+ },
+ },
+ });
+ e
+ }
+
+ let mut queue = ObserverPublishQueue::default();
+ // 50 × 1KB chunks under ONE messageId merge into a single pending
+ // coalescer entry — the oldest item anywhere in the queue.
+ let merged_text = "m".repeat(1_000);
+ let merged_sources = 50u64;
+ for seq in 1..=merged_sources {
+ queue.ingest(chunk(seq, "message-merged", &merged_text));
+ }
+ // Flood with distinct-key 50KB chunks until the byte budget evicts
+ // the oldest entries — the merged entry goes first.
+ let flood_text = "f".repeat(50_000);
+ let flood = 100u64;
+ for seq in 1..=flood {
+ queue.ingest(chunk(
+ merged_sources + seq,
+ &format!("message-{seq}"),
+ &flood_text,
+ ));
+ }
+
+ assert!(
+ walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES,
+ "eviction must restore the byte budget (entry-walked), got {}",
+ walked_retained_bytes(&queue)
+ );
+ let frames = drain_frames(&mut queue);
+ assert!(
+ !frames
+ .iter()
+ .flat_map(frame_seqs)
+ .any(|seq| seq <= merged_sources),
+ "the merged entry (globally oldest) must have been evicted"
+ );
+ // Every survivor is an unmerged distinct-key chunk (1 source each),
+ // so source-event accounting must close exactly: the merged entry's
+ // eviction charges all 50 sources.
+ let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum();
+ assert_eq!(
+ survived + queue.dropped_events,
+ merged_sources + flood,
+ "accounting: published sources + dropped sources == ingested"
+ );
+ }
+
+ /// Sami's M13 / Max's forced-flush probe: the OTHER eviction arm. A
+ /// merged entry FLUSHED into the publish FIFO (by a non-chunk event) must
+ /// still charge every absorbed source on eviction — the FIFO stores the
+ /// per-entry count precisely so the ledger survives flush. The
+ /// coalescer-side regression above never exercises this arm; mutating the
+ /// FIFO eviction to `dropped += 1` survived all 687 tests until this one.
+ #[test]
+ fn evicting_a_flushed_merged_entry_from_the_fifo_accounts_every_source_event() {
+ fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent {
+ let mut e = event(seq, "acp_read", Some("chan-a"));
+ e.payload = serde_json::json!({
+ "jsonrpc": "2.0",
+ "method": "session/update",
+ "params": {
+ "sessionId": "session-1",
+ "update": {
+ "sessionUpdate": "agent_message_chunk",
+ "messageId": message_id,
+ "content": { "type": "text", "text": text },
+ },
+ },
+ });
+ e
+ }
+
+ let mut queue = ObserverPublishQueue::default();
+ // 50 × 1KB chunks merge under one messageId in the coalescer…
+ let merged_text = "m".repeat(1_000);
+ let merged_sources = 50u64;
+ for seq in 1..=merged_sources {
+ queue.ingest(chunk(seq, "message-merged", &merged_text));
+ }
+ // …then a non-chunk event force-flushes the merged entry into the
+ // publish FIFO. From here eviction happens on the FIFO arm.
+ queue.ingest(event(merged_sources + 1, "tool_call", Some("chan-a")));
+ assert!(
+ queue.coalescer.pending.is_empty(),
+ "the non-chunk event must have flushed the merged entry"
+ );
+ assert_eq!(
+ queue.events.front().expect("flushed entry queued").1,
+ merged_sources,
+ "the FIFO front must carry the merged source count"
+ );
+
+ // Distinct-key flood forces byte-budget eviction of the FIFO front.
+ let flood_text = "f".repeat(50_000);
+ let flood = 100u64;
+ for seq in 1..=flood {
+ queue.ingest(chunk(
+ merged_sources + 1 + seq,
+ &format!("message-{seq}"),
+ &flood_text,
+ ));
+ }
+
+ assert!(
+ walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES,
+ "eviction must restore the byte budget (entry-walked), got {}",
+ walked_retained_bytes(&queue)
+ );
+ let frames = drain_frames(&mut queue);
+ assert!(
+ !frames
+ .iter()
+ .flat_map(frame_seqs)
+ .any(|seq| seq <= merged_sources),
+ "the flushed merged entry (globally oldest) must have been evicted"
+ );
+ // Ledger in source units: survivors are unmerged (1 source each), the
+ // evicted merged FIFO entry must charge all 50 sources.
+ let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum();
+ let ingested = merged_sources + 1 + flood;
+ assert_eq!(
+ survived + queue.dropped_events,
+ ingested,
+ "accounting: published sources + dropped sources == ingested"
+ );
+ }
+
+ /// Under the byte budget the queue is lossless: every ingested event
+ /// publishes exactly once.
+ #[test]
+ fn under_budget_backlogs_are_lossless() {
+ let mut queue = queue_of(
+ (1..=200)
+ .map(|seq| event(seq, "acp_read", Some("chan-a")))
+ .collect(),
+ );
+ let frames = drain_frames(&mut queue);
+ let published: Vec = frames.iter().flat_map(frame_seqs).collect();
+ assert_eq!(published, (1..=200).collect::>());
+ assert_eq!(queue.dropped_events, 0);
+ }
+}
+
+#[cfg(test)]
+mod observer_publish_cadence_tests {
+ use super::*;
+ use nostr::Keys;
+
+ /// Let every spawned task (publisher loop, test_pair forwarder) run to
+ /// quiescence WITHOUT advancing paused time. `yield_now` keeps this task
+ /// runnable, so tokio's auto-advance never fires here — time only moves
+ /// when the test says so.
+ async fn settle() {
+ for _ in 0..64 {
+ tokio::task::yield_now().await;
+ }
+ }
+
+ fn recv_all(rx: &mut tokio::sync::mpsc::Receiver) -> Vec {
+ let mut out = Vec::new();
+ while let Ok(event) = rx.try_recv() {
+ out.push(event);
+ }
+ out
+ }
+
+ fn count_inner(owner: &Keys, event: &nostr::Event) -> usize {
+ let payload: serde_json::Value =
+ decrypt_observer_payload(owner, event).expect("decrypt frame");
+ match payload["payload"]["events"].as_array() {
+ Some(inner) => inner.len(),
+ None => 1,
+ }
+ }
+
+ fn emit_on(observer: &observer::ObserverHandle, channel: Option, marker: &str) {
+ observer.emit(
+ "test_event",
+ None,
+ &observer::context_for(channel, None, None),
+ serde_json::json!({ "marker": marker }),
+ );
+ }
+
+ /// THE regression Max demanded: with a backlog needing multiple frames
+ /// (two channels — a frame never mixes channels, so the backlog takes two
+ /// publish slots), no frame publishes before its tick. Startup publishes
+ /// NOTHING at t=0 (Sami's Finding 1: a full replay buffer must not burst
+ /// on reconnect), frame 1 arrives at +1s, frame 2 no earlier than +2s.
#[tokio::test(start_paused = true)]
- async fn starts_without_a_burst_and_spaces_frames() {
- let started = tokio::time::Instant::now();
- let mut pacer = ObserverPublishPacer::new();
+ async fn one_frame_per_second_and_no_startup_burst() {
+ let observer = observer::ObserverHandle::in_process();
+ let agent_keys = Keys::generate();
+ let owner_keys = Keys::generate();
+ let (publisher, mut published_rx) = RelayEventPublisher::test_pair();
+
+ // Interleave channels so the backlog cannot fit one frame: each run
+ // boundary forces a new publish slot.
+ let chan_a = uuid::Uuid::new_v4();
+ let chan_b = uuid::Uuid::new_v4();
+ emit_on(&observer, Some(chan_a), "a1");
+ emit_on(&observer, Some(chan_b), "b1");
+ emit_on(&observer, Some(chan_a), "a2");
+
+ let rx = observer.subscribe();
+ let snapshot = observer.snapshot();
+ assert_eq!(snapshot.len(), 3, "all three preloaded in the snapshot");
+
+ let task = tokio::spawn(run_relay_observer_publisher(
+ snapshot,
+ rx,
+ publisher,
+ agent_keys.clone(),
+ agent_keys.public_key().to_hex(),
+ owner_keys.public_key().to_hex(),
+ owner_keys.public_key(),
+ ));
+
+ // t=0: nothing may publish, no matter how full the snapshot was.
+ settle().await;
+ assert_eq!(
+ recv_all(&mut published_rx).len(),
+ 0,
+ "startup must not burst at t=0"
+ );
+
+ // t=0.999s: still nothing.
+ tokio::time::advance(Duration::from_millis(999)).await;
+ settle().await;
+ assert_eq!(
+ recv_all(&mut published_rx).len(),
+ 0,
+ "no frame may publish before the first tick"
+ );
+
+ // t=1s: exactly ONE frame — chan-a gathered queue-wide, so a1 AND a2
+ // ride the first slot together.
+ tokio::time::advance(Duration::from_millis(1)).await;
+ settle().await;
+ let frames = recv_all(&mut published_rx);
+ assert_eq!(frames.len(), 1, "tick 1 publishes exactly one frame");
+ assert_eq!(count_inner(&owner_keys, &frames[0]), 2, "a1 + a2 gathered");
+
+ // t=1.5s: between ticks, nothing.
+ tokio::time::advance(Duration::from_millis(500)).await;
+ settle().await;
+ assert_eq!(
+ recv_all(&mut published_rx).len(),
+ 0,
+ "frame 2 must wait for tick 2"
+ );
- pacer.wait().await;
- let first = tokio::time::Instant::now();
- pacer.wait().await;
- let second = tokio::time::Instant::now();
+ // t=2s: the chan-b frame drains on its own tick.
+ tokio::time::advance(Duration::from_millis(500)).await;
+ settle().await;
+ assert_eq!(recv_all(&mut published_rx).len(), 1, "tick 2: one frame");
- assert_eq!(first.duration_since(started), OBSERVER_PUBLISH_INTERVAL);
- assert_eq!(second.duration_since(first), OBSERVER_PUBLISH_INTERVAL);
+ // Backlog drained; a quiet tick publishes nothing.
+ tokio::time::advance(Duration::from_secs(1)).await;
+ settle().await;
+ assert_eq!(recv_all(&mut published_rx).len(), 0, "quiet tick is quiet");
+
+ task.abort();
}
+ /// Shutdown is NOT a burst bypass: when the producer closes with a
+ /// backlog, the remaining frames still publish one per tick, and the loop
+ /// exits only after the queue is empty — paced, lossless, in order.
+ #[tokio::test(start_paused = true)]
+ async fn shutdown_drain_is_paced_and_lossless() {
+ let observer = observer::ObserverHandle::in_process();
+ let agent_keys = Keys::generate();
+ let owner_keys = Keys::generate();
+ let (publisher, mut published_rx) = RelayEventPublisher::test_pair();
+
+ let chan_a = uuid::Uuid::new_v4();
+ let chan_b = uuid::Uuid::new_v4();
+ emit_on(&observer, Some(chan_a), "a1");
+ emit_on(&observer, Some(chan_b), "b1");
+ emit_on(&observer, Some(chan_a), "a2");
+
+ let rx = observer.subscribe();
+ let snapshot = observer.snapshot();
+ // Close the broadcast channel immediately: the entire drain happens
+ // in "shutdown" mode.
+ drop(observer);
+
+ let task = tokio::spawn(run_relay_observer_publisher(
+ snapshot,
+ rx,
+ publisher,
+ agent_keys.clone(),
+ agent_keys.public_key().to_hex(),
+ owner_keys.public_key().to_hex(),
+ owner_keys.public_key(),
+ ));
+
+ settle().await;
+ assert_eq!(
+ recv_all(&mut published_rx).len(),
+ 0,
+ "shutdown drain must not burst at t=0"
+ );
+
+ let mut markers = Vec::new();
+ for tick in 1..=2 {
+ tokio::time::advance(Duration::from_secs(1)).await;
+ settle().await;
+ let frames = recv_all(&mut published_rx);
+ assert_eq!(frames.len(), 1, "shutdown tick {tick}: exactly one frame");
+ let payload: serde_json::Value =
+ decrypt_observer_payload(&owner_keys, &frames[0]).expect("decrypt");
+ match payload["payload"]["events"].as_array() {
+ Some(inner) => markers.extend(
+ inner
+ .iter()
+ .map(|e| e["payload"]["marker"].as_str().unwrap().to_string()),
+ ),
+ None => markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()),
+ }
+ }
+ // Gather-packing: chan-a (a1+a2) ships tick 1, chan-b tick 2.
+ assert_eq!(markers, ["a1", "a2", "b1"], "paced drain loses nothing");
+
+ // Queue empty + closed: the loop must have exited on its own.
+ tokio::time::advance(Duration::from_secs(1)).await;
+ settle().await;
+ assert!(task.is_finished(), "publisher exits after paced drain");
+ }
+
+ /// Pins `MissedTickBehavior::Skip` (Sami's M6 mutant): when the publisher
+ /// misses ticks — relay backpressure can stall the tick arm past several
+ /// deadlines, since `publish_event` awaits a bounded mpsc — the interval
+ /// must fire ONE catch-up tick and realign, not fire once per missed
+ /// deadline. With `Burst`, a 10s stall against a multi-frame backlog
+ /// would replay all 10 missed ticks back-to-back: an unpaced burst that
+ /// bypasses exactly what the pacer exists to prevent.
#[tokio::test(start_paused = true)]
- async fn limits_frames_in_each_rolling_minute() {
- let mut pacer = ObserverPublishPacer::new();
- pacer.wait().await;
- let first = tokio::time::Instant::now();
- for _ in 1..OBSERVER_PUBLISH_LIMIT_PER_MINUTE {
- pacer.wait().await;
+ async fn missed_ticks_skip_instead_of_bursting() {
+ let observer = observer::ObserverHandle::in_process();
+ let agent_keys = Keys::generate();
+ let owner_keys = Keys::generate();
+ let (publisher, mut published_rx) = RelayEventPublisher::test_pair();
+
+ // Three channels => three frames pending (a frame never mixes
+ // channels), so a bursting interval would have work for every
+ // spurious catch-up tick.
+ for chan in 0..3 {
+ emit_on(&observer, Some(uuid::Uuid::new_v4()), &format!("c{chan}"));
}
+ let rx = observer.subscribe();
+ let snapshot = observer.snapshot();
+
+ let task = tokio::spawn(run_relay_observer_publisher(
+ snapshot,
+ rx,
+ publisher,
+ agent_keys.clone(),
+ agent_keys.public_key().to_hex(),
+ owner_keys.public_key().to_hex(),
+ owner_keys.public_key(),
+ ));
+ settle().await;
- pacer.wait().await;
- let ninety_first = tokio::time::Instant::now();
+ // Jump 10 seconds in ONE advance — the loop was never polled in
+ // between, exactly like a stall across 10 deadlines.
+ tokio::time::advance(Duration::from_secs(10)).await;
+ settle().await;
+ assert_eq!(
+ recv_all(&mut published_rx).len(),
+ 1,
+ "Skip: one catch-up frame after a stall — Burst would publish \
+ one per missed deadline"
+ );
- assert_eq!(ninety_first.duration_since(first), Duration::from_secs(60));
+ // The interval realigned: the remaining backlog stays paced.
+ tokio::time::advance(Duration::from_secs(1)).await;
+ settle().await;
+ assert_eq!(recv_all(&mut published_rx).len(), 1, "paced after realign");
+
+ task.abort();
}
}
@@ -5061,9 +6231,14 @@ mod observer_chunk_coalescer_tests {
let events = coalescer.ingest(non_chunk_event(3));
assert_eq!(events.len(), 2);
- assert_eq!(events[0].seq, 2);
- assert_eq!(chunk_text(&events[0]), "hello world");
- assert_eq!(events[1].kind, "turn_started");
+ assert_eq!(events[0].1.seq, 2);
+ assert_eq!(chunk_text(&events[0].1), "hello world");
+ assert_eq!(
+ events[0].0, 2,
+ "a merged entry reports every source chunk it absorbed"
+ );
+ assert_eq!(events[1].1.kind, "turn_started");
+ assert_eq!(events[1].0, 1);
}
#[test]
@@ -5084,8 +6259,8 @@ mod observer_chunk_coalescer_tests {
let events = coalescer.flush();
assert_eq!(events.len(), 2);
- assert_eq!(chunk_text(&events[0]), "answer");
- assert_eq!(chunk_text(&events[1]), "thinking");
+ assert_eq!(chunk_text(&events[0].1), "answer");
+ assert_eq!(chunk_text(&events[1].1), "thinking");
}
}
@@ -5129,7 +6304,7 @@ mod build_mcp_servers_tests {
memory_enabled: false,
model: None,
session_title: None,
- permission_mode: config::PermissionMode::BypassPermissions,
+ permission_mode: config::PermissionMode::DontAsk,
respond_to: config::RespondTo::Anyone,
dm_policy: config::DmPolicy::Anyone,
respond_to_allowlist: std::collections::HashSet::new(),
@@ -5137,6 +6312,7 @@ mod build_mcp_servers_tests {
persona_env_vars: vec![],
has_generated_codex_config: false,
relay_observer: false,
+ exit_after_inactivity_secs: 0,
lazy_pool: false,
agent_owner: None,
no_base_prompt: false,
@@ -5505,7 +6681,7 @@ mod error_outcome_emission_tests {
memory_enabled: false,
model: None,
session_title: None,
- permission_mode: config::PermissionMode::BypassPermissions,
+ permission_mode: config::PermissionMode::DontAsk,
respond_to: config::RespondTo::Anyone,
dm_policy: config::DmPolicy::Anyone,
respond_to_allowlist: HashSet::new(),
@@ -5513,6 +6689,7 @@ mod error_outcome_emission_tests {
persona_env_vars: vec![],
has_generated_codex_config: false,
relay_observer: false,
+ exit_after_inactivity_secs: 0,
lazy_pool: false,
agent_owner: None,
no_base_prompt: false,
diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs
index dd97789306..067c80fd54 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -31,7 +31,8 @@ use uuid::Uuid;
use crate::acp::{
extract_model_config_options, extract_model_state, model_in_catalog,
- resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason,
+ resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod,
+ StopReason, SystemPromptTransport,
};
use crate::config::{compose_session_title, DedupMode, PermissionMode};
use crate::observer;
@@ -224,6 +225,13 @@ pub struct OwnedAgent {
pub protocol_version: u32,
}
+/// Package name reported by `claude-agent-acp` in its `initialize` response.
+/// Any adapter reporting this name supports `_meta.systemPrompt: {append: ...}`
+/// on `session/new` — the feature landed in v0.6.0 (Oct 2025), before the
+/// `@zed-industries/claude-code-acp` → `@agentclientprotocol/claude-agent-acp`
+/// rename, so the new name is a reliable capability gate.
+const CLAUDE_AGENT_ACP_NAME: &str = "@agentclientprotocol/claude-agent-acp";
+
fn has_system_prompt_support(
protocol_version: u32,
agent_name: &str,
@@ -231,20 +239,25 @@ fn has_system_prompt_support(
) -> bool {
if agent_name == "goose" {
goose_system_prompt_supported == Some(true)
+ } else if agent_name == CLAUDE_AGENT_ACP_NAME {
+ true
} else {
protocol_version >= 2
}
}
-fn session_new_system_prompt(
+fn session_new_system_prompt<'a>(
is_goose: bool,
protocol_version: u32,
- prompt: Option<&str>,
-) -> Option<&str> {
- if is_goose || protocol_version < 2 {
+ agent_name: &str,
+ prompt: Option<&'a str>,
+) -> Option> {
+ if is_goose || (protocol_version < 2 && agent_name != CLAUDE_AGENT_ACP_NAME) {
None
+ } else if agent_name == CLAUDE_AGENT_ACP_NAME {
+ prompt.map(SystemPromptTransport::ClaudeMeta)
} else {
- prompt
+ prompt.map(SystemPromptTransport::Field)
}
}
@@ -1118,13 +1131,32 @@ const UNKNOWN_CHANNEL_NAME: &str = "unknown";
async fn resolve_new_session_channel_context(
channel_info: &ChannelInfoResolver,
channel_id: Uuid,
-) -> (bool, Option) {
+) -> (bool, Option, Option) {
let Some(info) = channel_info.resolve(channel_id).await else {
- return (true, None);
+ return (true, None, None);
};
let is_dm = info.channel_type == "dm";
let title_channel = (!is_dm && info.name != UNKNOWN_CHANNEL_NAME).then_some(info.name);
- (is_dm, title_channel)
+ (is_dm, title_channel, Some(info.channel_type))
+}
+
+/// What a brand-new ACP session is seeded with, beyond the agent and its
+/// `PromptContext`: the memory blocks to frame into the system prompt and the
+/// channel the session belongs to.
+///
+/// Grouped rather than passed loose so the heartbeat path can say "no channel,
+/// no memory" as `SessionSeed::default()` instead of five bare `None`s.
+#[derive(Default)]
+struct SessionSeed<'a> {
+ /// `[Agent Memory — core]` block, when the agent has one.
+ agent_core: Option<&'a str>,
+ /// `[Channel Canvas]` block for the originating channel.
+ agent_canvas: Option<&'a str>,
+ /// Channel name for the session title — `None` for DMs, unresolved and
+ /// unnamed channels, which then get an unqualified title.
+ channel_name: Option<&'a str>,
+ channel_id: Option,
+ channel_type: Option<&'a str>,
}
/// Create a new ACP session via `session_new_full()`, populate model capabilities
@@ -1136,9 +1168,7 @@ async fn resolve_new_session_channel_context(
async fn create_session_and_apply_model(
agent: &mut OwnedAgent,
ctx: &PromptContext,
- agent_core: Option<&str>,
- agent_canvas: Option<&str>,
- channel_name: Option<&str>,
+ seed: SessionSeed<'_>,
session_mcp_servers: Vec,
) -> Result {
// Build base_prompt + system_prompt + agent core + canvas metadata into a
@@ -1154,22 +1184,26 @@ async fn create_session_and_apply_model(
framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()),
ctx.team_instructions.as_deref(),
),
- agent_core,
+ seed.agent_core,
),
- agent_canvas,
+ seed.agent_canvas,
);
let session_title = ctx
.session_title
.as_deref()
- .map(|agent_name| compose_session_title(agent_name, channel_name));
-
- let mcp_servers = ctx
- .mcp_servers
- .iter()
- .cloned()
- .chain(session_mcp_servers)
- .collect();
+ .map(|agent_name| compose_session_title(agent_name, seed.channel_name));
+ // Git-origin env goes on the configured servers only; the per-session
+ // browser server is addressed by its own activity id, not by git origin.
+ let mcp_servers: Vec = mcp_servers_with_git_origin(
+ &ctx.mcp_servers,
+ seed.channel_id,
+ seed.channel_type,
+ ctx.session_title.as_deref(),
+ )
+ .into_iter()
+ .chain(session_mcp_servers)
+ .collect();
let resp = agent
.acp
.session_new_full(
@@ -1178,6 +1212,7 @@ async fn create_session_and_apply_model(
session_new_system_prompt(
is_goose,
agent.protocol_version,
+ &agent.agent_name,
combined_system_prompt.as_deref(),
),
session_title.as_deref(),
@@ -1266,7 +1301,7 @@ async fn create_session_and_apply_model(
// Apply permission mode if not the agent's built-in default AND the agent
// advertises the requested mode in session/new. Agents that don't support
// the mode (e.g., goose crashes on unrecognized set_config_option values)
- // are safely skipped — the harness auto-approves via handle_permission_request.
+ // are safely skipped — the harness rejects interactive permission requests.
if !ctx.permission_mode.is_default()
&& agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str())
{
@@ -1276,6 +1311,34 @@ async fn create_session_and_apply_model(
Ok(resp.session_id)
}
+fn mcp_servers_with_git_origin(
+ servers: &[McpServer],
+ channel_id: Option,
+ channel_type: Option<&str>,
+ agent_name: Option<&str>,
+) -> Vec {
+ let mut servers = servers.to_vec();
+ let origin = match (channel_id, channel_type) {
+ (Some(channel_id), Some("stream")) => Some(EnvVar {
+ name: "BUZZ_GIT_ORIGIN_CHANNEL_ID".into(),
+ value: channel_id.to_string(),
+ }),
+ (Some(_), _) => agent_name
+ .filter(|name| !name.trim().is_empty())
+ .map(|name| EnvVar {
+ name: "BUZZ_GIT_ORIGIN_AGENT_NAME".into(),
+ value: name.trim().to_string(),
+ }),
+ (None, _) => None,
+ };
+ if let Some(origin) = origin {
+ for server in &mut servers {
+ server.env.push(origin.clone());
+ }
+ }
+ servers
+}
+
/// Send the appropriate ACP model-switch request with a timeout.
///
/// On timeout or error, logs a warning and returns — the caller proceeds
@@ -1351,11 +1414,7 @@ async fn apply_model_switch(
Ok(())
}
-/// Set the session permission mode via `session/set_config_option`.
-///
-/// Non-fatal for most errors: logs and proceeds. The agent falls back
-/// to its default permission mode (`"default"`), which still works via
-/// Check if the agent's `session/new` response advertises a given mode ID
+/// Check whether the agent's `session/new` response advertises a given mode ID
/// in `result.modes.availableModes[].id`. Returns `false` if the modes
/// field is absent or the mode isn't listed.
fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) -> bool {
@@ -1371,7 +1430,11 @@ fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str)
.unwrap_or(false)
}
-/// per-tool auto-approval in `handle_permission_request`.
+/// Set the session permission mode via `session/set_config_option`.
+///
+/// Non-fatal for most errors: logs and proceeds. The agent falls back to its
+/// default mode, and any interactive permission request is rejected by
+/// `handle_permission_request`.
///
/// **Fatal exception:** if the agent process exits (e.g., goose crashes on
/// unrecognized methods), returns `Err(AgentExited)` so the caller can respawn.
@@ -1411,7 +1474,7 @@ async fn apply_permission_mode(
Ok(Err(e)) => {
tracing::warn!(
target: "pool::permission",
- "failed to set permission mode {wire:?}: {e} — falling back to per-tool auto-approval"
+ "failed to set permission mode {wire:?}: {e} — falling back to per-tool rejection"
);
}
Err(_) => {
@@ -1777,14 +1840,15 @@ pub async fn run_prompt_task(
// Channel name for the session title, from the same single resolve the
// canvas DM check uses — see `resolve_new_session_channel_context`.
let mut title_channel: Option = None;
+ let mut origin_channel_type: Option = None;
if let PromptSource::Channel(cid) = &source {
let is_new_channel_session = !agent.state.sessions.contains_key(cid);
let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid);
- let needs_title = is_new_channel_session && ctx.session_title.is_some();
- if needs_canvas || needs_title {
- let (is_dm, resolved_channel) =
+ if is_new_channel_session {
+ let (is_dm, resolved_channel, resolved_channel_type) =
resolve_new_session_channel_context(&ctx.channel_info, *cid).await;
title_channel = resolved_channel;
+ origin_channel_type = resolved_channel_type;
// A confirmed DM never receives a canvas section; an undeterminable
// channel type fails closed as a DM for the same reason.
if needs_canvas && !is_dm {
@@ -1859,9 +1923,13 @@ pub async fn run_prompt_task(
create_session_and_apply_model(
&mut agent,
&ctx,
- agent_core.as_deref(),
- agent_canvas.as_deref(),
- title_channel.as_deref(),
+ SessionSeed {
+ agent_core: agent_core.as_deref(),
+ agent_canvas: agent_canvas.as_deref(),
+ channel_name: title_channel.as_deref(),
+ channel_id: Some(*cid),
+ channel_type: origin_channel_type.as_deref(),
+ },
browser_servers,
)
.await
@@ -1913,8 +1981,13 @@ pub async fn run_prompt_task(
if let Some(sid) = &agent.state.heartbeat_session {
(sid.clone(), false)
} else {
- match create_session_and_apply_model(&mut agent, &ctx, None, None, None, vec![])
- .await
+ match create_session_and_apply_model(
+ &mut agent,
+ &ctx,
+ SessionSeed::default(),
+ vec![],
+ )
+ .await
{
Ok(sid) => {
tracing::info!(
@@ -3942,7 +4015,11 @@ pub(crate) fn build_turn_metric_counts(
// from input+output.
total_tokens: usage.turn_total_tokens,
cost_usd: usage.turn_cost_usd,
- cache_read_tokens: None,
+ // Field-local: present when the cumulative counter was monotonic
+ // across this turn. Zero means no cache hits this turn (not absent).
+ cache_read_tokens: usage.turn_cache_read_tokens,
+ // buzz-agent does not emit a cache-write count on the wire today;
+ // leave None rather than deriving it from other fields.
cache_write_tokens: None,
})
} else {
@@ -3960,7 +4037,13 @@ pub(crate) fn build_turn_metric_counts(
// one. Never derived from input+output (NIP-AM MUST NOT).
total_tokens: usage.cumulative_total_tokens,
cost_usd: usage.cumulative_cost_usd,
- cache_read_tokens: None,
+ // Session-cumulative cache-read tokens; None when the harness never
+ // reported this field (e.g. goose or older buzz-agent sessions).
+ // Passes through directly — do not wrap in Some() as the field already
+ // carries provenance (None vs Some(0) are distinct meanings).
+ cache_read_tokens: usage.cumulative_cache_read_tokens,
+ // buzz-agent does not emit a cache-write count on the wire today;
+ // leave None rather than deriving it from other fields.
cache_write_tokens: None,
});
(turn_counts, cumulative_counts)
@@ -4283,6 +4366,50 @@ mod tests {
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use serde_json::json;
+ fn test_mcp_server() -> McpServer {
+ McpServer {
+ name: "dev".into(),
+ command: "buzz-dev-mcp".into(),
+ args: vec![],
+ env: vec![],
+ }
+ }
+
+ #[test]
+ fn public_session_forwards_channel_origin_to_mcp() {
+ let channel_id = Uuid::new_v4();
+ let servers = mcp_servers_with_git_origin(
+ &[test_mcp_server()],
+ Some(channel_id),
+ Some("stream"),
+ None,
+ );
+ assert!(servers[0].env.iter().any(|entry| {
+ entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID" && entry.value == channel_id.to_string()
+ }));
+ assert!(!servers[0]
+ .env
+ .iter()
+ .any(|entry| entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME"));
+ }
+
+ #[test]
+ fn private_session_forwards_agent_name_without_channel_id() {
+ let servers = mcp_servers_with_git_origin(
+ &[test_mcp_server()],
+ Some(Uuid::new_v4()),
+ Some("dm"),
+ Some("Builder"),
+ );
+ assert!(servers[0].env.iter().any(|entry| {
+ entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME" && entry.value == "Builder"
+ }));
+ assert!(!servers[0]
+ .env
+ .iter()
+ .any(|entry| entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID"));
+ }
+
// These pin the initial_message dispatch path (run_prompt_task, ~line 855):
// a legacy agent WITH a base_prompt must get [Base] prepended to the user
// message. This is the exact regression that shipped in the round-2 bug.
@@ -4311,18 +4438,48 @@ mod tests {
assert!(has_system_prompt_support(2, "goose", Some(true)));
assert!(has_system_prompt_support(1, "goose", Some(true)));
assert!(has_system_prompt_support(2, "buzz-agent", None));
+ // Goose never receives system prompt via session/new (uses post-hoc method).
assert_eq!(
- session_new_system_prompt(true, 2, Some("instructions")),
+ session_new_system_prompt(true, 2, "goose", Some("instructions")),
None
);
+ // Protocol-v2 non-goose gets Field transport.
assert_eq!(
- session_new_system_prompt(false, 2, Some("instructions")),
- Some("instructions")
+ session_new_system_prompt(false, 2, "buzz-agent", Some("instructions")),
+ Some(SystemPromptTransport::Field("instructions"))
);
+ // Protocol-v1 non-goose, non-claude gets None (legacy user-message framing).
assert_eq!(
- session_new_system_prompt(false, 1, Some("instructions")),
+ session_new_system_prompt(false, 1, "codex", Some("instructions")),
None
);
+ // claude-agent-acp gets ClaudeMeta transport regardless of protocol version.
+ assert_eq!(
+ session_new_system_prompt(false, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")),
+ Some(SystemPromptTransport::ClaudeMeta("instructions"))
+ );
+ assert_eq!(
+ session_new_system_prompt(true, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")),
+ None,
+ "goose path must never produce a transport even when agent_name matches"
+ );
+ }
+
+ #[test]
+ fn claude_agent_acp_has_system_prompt_support_regardless_of_protocol_version() {
+ // claude-agent-acp declares protocolVersion:1 but supports _meta.systemPrompt;
+ // has_system_prompt_support must return true so user-message framing is suppressed.
+ assert!(has_system_prompt_support(1, CLAUDE_AGENT_ACP_NAME, None));
+ assert!(has_system_prompt_support(2, CLAUDE_AGENT_ACP_NAME, None));
+ }
+
+ #[test]
+ fn old_zed_adapter_name_falls_through_to_protocol_version_gate() {
+ // The renamed @zed-industries package predates the _meta.systemPrompt support,
+ // so it must not be treated as capable and stays on legacy user-message framing.
+ let old_name = "@zed-industries/claude-code-acp";
+ assert!(!has_system_prompt_support(1, old_name, None));
+ assert!(has_system_prompt_support(2, old_name, None));
}
#[test]
@@ -6532,10 +6689,12 @@ mod tests {
turn_output_tokens: Some(50),
turn_total_tokens: None,
turn_cost_usd: None,
+ turn_cache_read_tokens: None,
cumulative_input_tokens: 100,
cumulative_output_tokens: 50,
cumulative_total_tokens: None,
cumulative_cost_usd: None,
+ cumulative_cache_read_tokens: None,
model: None,
};
// owner_pubkey = None → early return, no panic.
@@ -6566,10 +6725,12 @@ mod tests {
turn_output_tokens: Some(80),
turn_total_tokens: None,
turn_cost_usd: Some(0.001),
+ turn_cache_read_tokens: None,
cumulative_input_tokens: 200,
cumulative_output_tokens: 80,
cumulative_total_tokens: None,
cumulative_cost_usd: Some(0.001),
+ cumulative_cache_read_tokens: None,
model: None,
};
// Will try to publish and fail (no real relay) but must not panic.
@@ -6601,10 +6762,12 @@ mod tests {
turn_output_tokens: Some(20),
turn_total_tokens: None,
turn_cost_usd: None,
+ turn_cache_read_tokens: None,
cumulative_input_tokens: 150,
cumulative_output_tokens: 70,
cumulative_total_tokens: None,
cumulative_cost_usd: None,
+ cumulative_cache_read_tokens: None,
model: None,
};
// Must not panic; HTTP submit will fail (no real relay) — that's fine.
@@ -6636,10 +6799,12 @@ mod tests {
turn_output_tokens: None,
turn_total_tokens: None,
turn_cost_usd: None,
+ turn_cache_read_tokens: None,
cumulative_input_tokens: 400,
cumulative_output_tokens: 100,
cumulative_total_tokens: None,
cumulative_cost_usd: None,
+ cumulative_cache_read_tokens: None,
model: None,
};
// Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic.
@@ -6668,10 +6833,12 @@ mod tests {
turn_output_tokens: Some(30),
turn_total_tokens: Some(130), // genuine per-turn total
turn_cost_usd: None,
+ turn_cache_read_tokens: None,
cumulative_input_tokens: 500,
cumulative_output_tokens: 120,
cumulative_total_tokens: Some(620), // genuine cumulative total
cumulative_cost_usd: None,
+ cumulative_cache_read_tokens: None,
model: None,
};
@@ -6715,10 +6882,12 @@ mod tests {
turn_output_tokens: Some(60),
turn_total_tokens: None, // provider did not supply a total
turn_cost_usd: None,
+ turn_cache_read_tokens: None,
cumulative_input_tokens: 200,
cumulative_output_tokens: 60,
cumulative_total_tokens: None, // session has no total
cumulative_cost_usd: None,
+ cumulative_cache_read_tokens: None,
model: None,
};
@@ -6758,6 +6927,96 @@ mod tests {
);
}
+ /// A payload with nonzero `accumulatedCachedInputTokens` on the second turn
+ /// must produce a kind:44200 payload where `cumulative.cacheReadTokens` is
+ /// nonzero and `turn.cacheReadTokens` reflects the per-turn delta.
+ /// This is the acceptance-criterion test: it proves the threading is live,
+ /// not hardcoded to None.
+ #[test]
+ fn test_build_turn_metric_counts_cache_read_tokens_thread_through() {
+ // Wire-parse a buzz-agent payload with cache, run it through the tracker,
+ // and verify the published TokenCounts carry the cache field.
+ let raw1 = serde_json::json!({
+ "sessionId": "cache-sess",
+ "update": {
+ "sessionUpdate": "usage_update",
+ "accumulatedInputTokens": 15_091,
+ "accumulatedOutputTokens": 156,
+ "accumulatedCachedInputTokens": 5_033,
+ }
+ });
+ let raw2 = serde_json::json!({
+ "sessionId": "cache-sess",
+ "update": {
+ "sessionUpdate": "usage_update",
+ "accumulatedInputTokens": 28_500,
+ "accumulatedOutputTokens": 310,
+ "accumulatedCachedInputTokens": 11_000,
+ }
+ });
+
+ let mut tracker = crate::usage::UsageTracker::default();
+
+ // Turn 1 — establish baseline (delta unreliable, but cumulative still present).
+ tracker.begin_turn("cache-sess");
+ if let crate::usage::GooseSessionUpdateVariant::UsageUpdate(p) =
+ serde_json::from_value::(raw1)
+ .unwrap()
+ .update
+ {
+ tracker.record("cache-sess", &p);
+ }
+ let t1 = tracker.take().expect("turn 1");
+
+ // Turn 1: cumulative must carry the cache count; turn delta is None (no baseline).
+ let (turn1, cum1) = crate::pool::build_turn_metric_counts(&t1);
+ // delta_reliable = false on first turn → no turn counts.
+ assert!(turn1.is_none(), "first turn: no reliable turn counts");
+ let cum1 = cum1.expect("cumulative always present");
+ assert_eq!(
+ cum1.cache_read_tokens,
+ Some(5_033),
+ "cumulative.cacheReadTokens must be 5033 after turn 1"
+ );
+
+ // Turn 2 — delta reliable.
+ tracker.begin_turn("cache-sess");
+ if let crate::usage::GooseSessionUpdateVariant::UsageUpdate(p) =
+ serde_json::from_value::(raw2)
+ .unwrap()
+ .update
+ {
+ tracker.record("cache-sess", &p);
+ }
+ let t2 = tracker.take().expect("turn 2");
+
+ let (turn2, cum2) = crate::pool::build_turn_metric_counts(&t2);
+
+ let turn2 = turn2.expect("reliable turn counts on turn 2");
+ // Per-turn cache delta: 11_000 - 5_033 = 5_967.
+ assert_eq!(
+ turn2.cache_read_tokens,
+ Some(5_967),
+ "turn.cacheReadTokens must be the per-turn delta"
+ );
+ // cache_write_tokens is always None — buzz-agent doesn't emit it.
+ assert!(
+ turn2.cache_write_tokens.is_none(),
+ "cache_write_tokens must be None — not emitted by buzz-agent"
+ );
+
+ let cum2 = cum2.expect("cumulative always present");
+ assert_eq!(
+ cum2.cache_read_tokens,
+ Some(11_000),
+ "cumulative.cacheReadTokens must be 11_000 after turn 2"
+ );
+ assert!(
+ cum2.cache_write_tokens.is_none(),
+ "cache_write_tokens must be None on cumulative too"
+ );
+ }
+
fn make_prompt_context_no_owner() -> PromptContext {
let agent_keys = nostr::Keys::generate();
make_prompt_context_impl(&agent_keys, None)
@@ -7188,12 +7447,14 @@ mod tests {
let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]);
let (resolver, requests, server) = counting_resolver(response).await;
- let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await;
+ let (is_dm, title_channel, channel_type) =
+ resolve_new_session_channel_context(&resolver, id).await;
assert!(!is_dm, "a stream channel is not a DM");
assert_eq!(title_channel.as_deref(), Some("buzz-dev"));
+ assert_eq!(channel_type.as_deref(), Some("stream"));
assert_eq!(requests.load(Ordering::SeqCst), 1);
- let (_, again) = resolve_new_session_channel_context(&resolver, id).await;
+ let (_, again, _) = resolve_new_session_channel_context(&resolver, id).await;
assert_eq!(again.as_deref(), Some("buzz-dev"));
assert_eq!(
requests.load(Ordering::SeqCst),
@@ -7211,8 +7472,10 @@ mod tests {
let response = channel_metadata_response(id, &[["name", "DM"], ["t", "dm"]]);
let (resolver, _requests, server) = counting_resolver(response).await;
- let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await;
+ let (is_dm, title_channel, channel_type) =
+ resolve_new_session_channel_context(&resolver, id).await;
assert!(is_dm);
+ assert_eq!(channel_type.as_deref(), Some("dm"));
assert_eq!(
title_channel, None,
"a DM name must never reach the session title"
@@ -7229,7 +7492,7 @@ mod tests {
let response = channel_metadata_response(id, &[["t", "stream"]]);
let (resolver, _requests, server) = counting_resolver(response).await;
- let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await;
+ let (is_dm, title_channel, _) = resolve_new_session_channel_context(&resolver, id).await;
assert!(!is_dm, "a nameless stream channel is still not a DM");
assert_eq!(
title_channel, None,
@@ -7249,10 +7512,11 @@ mod tests {
let (resolver, requests, server) = counting_resolver(json!([])).await;
- let (is_dm, title_channel) =
+ let (is_dm, title_channel, channel_type) =
resolve_new_session_channel_context(&resolver, Uuid::new_v4()).await;
assert!(is_dm, "an undeterminable channel type must fail closed");
assert_eq!(title_channel, None, "unresolved channels get a bare title");
+ assert_eq!(channel_type, None);
assert_eq!(
requests.load(Ordering::SeqCst),
2,
diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs
index 1914fba045..3dd6f67076 100644
--- a/crates/buzz-acp/src/queue.rs
+++ b/crates/buzz-acp/src/queue.rs
@@ -646,6 +646,11 @@ impl EventQueue {
self.in_flight_channels.contains(&channel_id)
}
+ /// Whether any channel currently has a turn in flight.
+ pub fn has_in_flight(&self) -> bool {
+ !self.in_flight_channels.is_empty()
+ }
+
// ── Goose-native steer withhold (side table) ──────────────────────────
//
// While a goose-native `_goose/unstable/session/steer` write is in flight
diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs
index aea5cee077..2cbb82411f 100644
--- a/crates/buzz-acp/src/relay.rs
+++ b/crates/buzz-acp/src/relay.rs
@@ -106,9 +106,12 @@ const REQ_PACING_INTERVAL: Duration = Duration::from_millis(125);
/// blocked for more than one REQ's worth of I/O between drain ticks.
const DRAIN_BUDGET_PER_ITER: usize = 1;
/// Maximum observer telemetry frames parked while the rate-limit gate is armed
-/// (or the socket is down). The upstream pacer feeds at most ~6 frames/s, so
-/// this covers ~40 s of gating; beyond that the oldest frames are dropped with
-/// visible accounting (`gated_observer_dropped`).
+/// (or the socket is down). The upstream publisher ships at most ONE batched
+/// frame per second GLOBALLY (one publish slot per tick, regardless of how
+/// many channels are active), so this covers ~4 minutes of gating; beyond that
+/// the oldest frames are dropped with visible accounting
+/// (`gated_observer_dropped`). Note each dropped frame may carry a whole batch
+/// of events, so event-level loss is larger than the frame count.
const GATED_OBSERVER_QUEUE_CAP: usize = 256;
use std::time::Instant;
diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs
index 1629eee935..56b772d12c 100644
--- a/crates/buzz-acp/src/usage.rs
+++ b/crates/buzz-acp/src/usage.rs
@@ -85,12 +85,16 @@ pub(crate) struct UsageUpdatePayload {
pub context_limit: u64,
pub accumulated_input_tokens: u64,
pub accumulated_output_tokens: u64,
- /// The cache-served subset of `accumulated_input_tokens`. Optional — goose
- /// does not send it, and buzz-agent only reports a non-zero value when the
- /// provider returned a cache split, so `0` legitimately means either "no
- /// cache hits" or "provider reported none".
- #[serde(default)]
- pub accumulated_cached_input_tokens: u64,
+ /// The cache-served subset of `accumulated_input_tokens`.
+ ///
+ /// `None` when the harness did not include the field (e.g. goose, which
+ /// never emits it). `Some(0)` when the harness explicitly reported zero
+ /// cache hits. The distinction matters: `None` means "we don't know",
+ /// while `Some(0)` means "provider confirmed no cache was used".
+ ///
+ /// Do NOT use `#[serde(default)]` here — that would collapse the absent
+ /// case into `Some(0)` and destroy provenance in the append-only archive.
+ pub accumulated_cached_input_tokens: Option,
pub accumulated_cost: Option,
/// Session-cumulative genuine provider total tokens. Optional — only
/// emitted by buzz-agent when every turn in the session so far supplied a
@@ -125,6 +129,12 @@ struct SessionState {
/// `None` when the session has never emitted a provider total (Unseen) or
/// when any prior turn lacked one (poisoned).
last_total: Option,
+ /// Cumulative cache-read input tokens at the end of the LAST PUBLISHED turn.
+ /// `None` when the harness has never reported this field (e.g. goose).
+ /// `Some(n)` when at least one payload included the field. Field-local:
+ /// a decrease in this counter taints only the cache-read delta, not
+ /// `delta_reliable` or the input/output deltas.
+ last_cached_input: Option,
}
/// Per-turn usage record exposed to `TurnCompletionGuard` for NIP-AM publishing.
@@ -151,6 +161,12 @@ pub struct TurnUsage {
/// Per-turn cost delta (`current − previous`); `None` when unreliable or
/// either snapshot is missing.
pub turn_cost_usd: Option,
+ /// Per-turn cache-read token delta (`current − previous`); `None` when no
+ /// baseline exists, either snapshot is `None` (harness did not report it),
+ /// or the cumulative counter decreased (field-local taint). Field-local:
+ /// a decrease here never flips `delta_reliable` or invalidates the
+ /// input/output deltas.
+ pub turn_cache_read_tokens: Option,
/// Session-cumulative input tokens as reported by goose at end of turn.
pub cumulative_input_tokens: u64,
/// Session-cumulative output tokens as reported by goose at end of turn.
@@ -160,6 +176,11 @@ pub struct TurnUsage {
pub cumulative_total_tokens: Option,
/// Session-cumulative estimated cost in USD; `None` if goose did not report it.
pub cumulative_cost_usd: Option,
+ /// Session-cumulative cache-read input tokens as reported by buzz-agent.
+ /// `None` when the harness has never reported this field (e.g. goose or
+ /// any harness that omits `accumulatedCachedInputTokens`).
+ /// `Some(0)` when the harness reported zero cache hits.
+ pub cumulative_cache_read_tokens: Option,
/// Effective model id for this turn (maps to NIP-AM `model`). `None` if the
/// harness did not include the model in its usage notification.
pub model: Option,
@@ -239,6 +260,7 @@ impl UsageTracker {
let current_output = payload.accumulated_output_tokens;
let current_cost = payload.accumulated_cost;
let current_total = payload.accumulated_total_tokens;
+ let current_cached_input = payload.accumulated_cached_input_tokens;
// Determine whether this session is currently in-flight so we know
// whether to set `pending`. We compute the delta regardless so that
@@ -294,6 +316,21 @@ impl UsageTracker {
None => None, // no baseline yet
};
+ // Cache-read token delta: field-local — never affects `delta_reliable`
+ // or the input/output deltas. Null when: no baseline exists, either
+ // snapshot is None (harness did not report the field), or the cumulative
+ // counter decreased (harness restart, overflow).
+ // Some(0) is a valid result when both snapshots are Some(0) — it means
+ // the harness confirmed zero cache hits this turn, not that data is absent.
+ let turn_cache_read = match self.sessions.get(session_id) {
+ Some(prev) => match (current_cached_input, prev.last_cached_input) {
+ (Some(cur), Some(p)) if cur >= p => Some(cur - p),
+ (Some(_), Some(_)) => None, // decrease → field-local taint
+ _ => None, // either snapshot absent → no delta
+ },
+ None => None, // no baseline yet
+ };
+
if is_in_flight {
// In-flight-match: update pending with the latest cumulative values.
// Baseline is NOT advanced here — it advances only on take().
@@ -305,10 +342,12 @@ impl UsageTracker {
turn_output_tokens: turn_output,
turn_total_tokens: turn_total,
turn_cost_usd: turn_cost,
+ turn_cache_read_tokens: turn_cache_read,
cumulative_input_tokens: current_input,
cumulative_output_tokens: current_output,
cumulative_total_tokens: current_total,
cumulative_cost_usd: current_cost,
+ cumulative_cache_read_tokens: current_cached_input,
model: payload.model.clone(),
});
} else if self.in_flight_session.is_none() {
@@ -327,6 +366,7 @@ impl UsageTracker {
last_output: current_output,
last_cost: current_cost,
last_total: current_total,
+ last_cached_input: current_cached_input,
},
);
}
@@ -355,6 +395,7 @@ impl UsageTracker {
last_output: record.cumulative_output_tokens,
last_cost: record.cumulative_cost_usd,
last_total: record.cumulative_total_tokens,
+ last_cached_input: record.cumulative_cache_read_tokens,
},
);
Some(record)
@@ -366,9 +407,9 @@ mod tests {
use super::*;
/// The camelCase key buzz-agent actually puts on the wire must land on the
- /// field. A rename mismatch here would deserialize to the serde default of
- /// 0, and every trial would price as if nothing had ever been cached — the
- /// exact silent failure this field was added to remove.
+ /// field. A rename mismatch here would deserialize to None, and every trial
+ /// would be treated as "not reported" — the exact silent failure this field
+ /// was added to remove.
#[test]
fn cached_input_tokens_deserialize_from_the_wire_key() {
let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({
@@ -379,13 +420,14 @@ mod tests {
"accumulatedCachedInputTokens": 5_033,
}))
.expect("payload must deserialize");
- assert_eq!(p.accumulated_cached_input_tokens, 5_033);
- assert!(p.accumulated_cached_input_tokens <= p.accumulated_input_tokens);
+ assert_eq!(p.accumulated_cached_input_tokens, Some(5_033));
+ assert!(p.accumulated_cached_input_tokens.unwrap() <= p.accumulated_input_tokens);
}
- /// goose does not send the field; its payloads must still deserialize.
+ /// goose does not send the field; its payloads must deserialize with None —
+ /// not zero — so that "not reported" is preserved distinct from "reported zero".
#[test]
- fn a_payload_without_the_cache_field_defaults_to_zero() {
+ fn a_payload_without_the_cache_field_deserializes_as_none() {
let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({
"used": 500,
"contextLimit": 200_000,
@@ -393,7 +435,28 @@ mod tests {
"accumulatedOutputTokens": 100,
}))
.expect("payload must deserialize without the cache field");
- assert_eq!(p.accumulated_cached_input_tokens, 0);
+ assert!(
+ p.accumulated_cached_input_tokens.is_none(),
+ "absent field must be None, not Some(0)"
+ );
+ }
+
+ /// A harness that explicitly reports zero cache hits must produce Some(0),
+ /// not None — so downstream analytics can distinguish "confirmed zero" from
+ /// "not reported".
+ #[test]
+ fn a_payload_with_explicit_zero_cache_field_deserializes_as_some_zero() {
+ let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({
+ "accumulatedInputTokens": 400,
+ "accumulatedOutputTokens": 100,
+ "accumulatedCachedInputTokens": 0,
+ }))
+ .expect("payload must deserialize with zero cache field");
+ assert_eq!(
+ p.accumulated_cached_input_tokens,
+ Some(0),
+ "explicit zero must be Some(0), not None"
+ );
}
fn payload(input: u64, output: u64, cost: Option) -> UsageUpdatePayload {
@@ -402,7 +465,7 @@ mod tests {
context_limit: 200_000,
accumulated_input_tokens: input,
accumulated_output_tokens: output,
- accumulated_cached_input_tokens: 0,
+ accumulated_cached_input_tokens: None,
accumulated_cost: cost,
accumulated_total_tokens: None,
model: None,
@@ -415,7 +478,7 @@ mod tests {
context_limit: 0,
accumulated_input_tokens: input,
accumulated_output_tokens: output,
- accumulated_cached_input_tokens: 0,
+ accumulated_cached_input_tokens: None,
accumulated_cost: cost,
accumulated_total_tokens: None,
model: None,
@@ -913,7 +976,7 @@ mod tests {
context_limit: 200_000,
accumulated_input_tokens: input,
accumulated_output_tokens: output,
- accumulated_cached_input_tokens: 0,
+ accumulated_cached_input_tokens: None,
accumulated_cost: cost,
accumulated_total_tokens: None,
model: model.map(str::to_string),
@@ -977,7 +1040,7 @@ mod tests {
context_limit: 200_000,
accumulated_input_tokens: input,
accumulated_output_tokens: output,
- accumulated_cached_input_tokens: 0,
+ accumulated_cached_input_tokens: None,
accumulated_cost: None,
accumulated_total_tokens: total,
model: None,
@@ -1132,4 +1195,320 @@ mod tests {
);
assert_eq!(usage.cumulative_total_tokens, Some(250));
}
+
+ // ── cache-read token threading ──────────────────────────────────────────
+
+ fn payload_with_cache(
+ input: u64,
+ output: u64,
+ cached_input: Option,
+ ) -> UsageUpdatePayload {
+ UsageUpdatePayload {
+ used: input + output,
+ context_limit: 200_000,
+ accumulated_input_tokens: input,
+ accumulated_output_tokens: output,
+ accumulated_cached_input_tokens: cached_input,
+ accumulated_cost: None,
+ accumulated_total_tokens: None,
+ model: None,
+ }
+ }
+
+ #[test]
+ fn cache_read_first_turn_produces_none_turn_delta_and_passes_cumulative_through() {
+ // First turn has no baseline → turn cache delta must be None, but
+ // cumulative_cache_read_tokens must carry the reported value through.
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-c1");
+ tracker.record("sess-c1", &payload_with_cache(1000, 200, Some(500)));
+ let usage = tracker.take().expect("pending");
+
+ assert!(
+ usage.turn_cache_read_tokens.is_none(),
+ "first turn: no baseline → cache delta must be None"
+ );
+ assert_eq!(
+ usage.cumulative_cache_read_tokens,
+ Some(500),
+ "cumulative cache read passes through on first turn"
+ );
+ assert!(!usage.delta_reliable, "first turn is unreliable");
+ }
+
+ #[test]
+ fn cache_read_second_turn_delta_computed_correctly() {
+ // Second turn: cumulative cached 500 → 1200, delta = 700.
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-c2");
+ tracker.record("sess-c2", &payload_with_cache(1000, 200, Some(500)));
+ let _ = tracker.take();
+
+ tracker.begin_turn("sess-c2");
+ tracker.record("sess-c2", &payload_with_cache(2000, 350, Some(1200)));
+ let usage = tracker.take().expect("pending");
+
+ assert!(usage.delta_reliable);
+ assert_eq!(
+ usage.turn_cache_read_tokens,
+ Some(700),
+ "cache delta = 1200 - 500 = 700"
+ );
+ assert_eq!(
+ usage.cumulative_cache_read_tokens,
+ Some(1200),
+ "cumulative cache passes through"
+ );
+ }
+
+ #[test]
+ fn cache_read_decrease_nulls_turn_cache_but_leaves_delta_reliable() {
+ // Cache counter decrease → cache delta None (field-local taint), but
+ // delta_reliable and input/output deltas are NOT affected.
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-c3");
+ tracker.record("sess-c3", &payload_with_cache(1000, 200, Some(800)));
+ let _ = tracker.take();
+
+ tracker.begin_turn("sess-c3");
+ // Cache counter decreased: 800 → 50.
+ tracker.record("sess-c3", &payload_with_cache(1500, 300, Some(50)));
+ let usage = tracker.take().expect("pending");
+
+ assert!(
+ usage.delta_reliable,
+ "cache decrease must NOT flip delta_reliable — field-local"
+ );
+ assert_eq!(
+ usage.turn_input_tokens,
+ Some(500),
+ "input/output delta unaffected by cache decrease"
+ );
+ assert_eq!(usage.turn_output_tokens, Some(100));
+ assert!(
+ usage.turn_cache_read_tokens.is_none(),
+ "cache counter decrease → turn_cache_read_tokens None (field-local taint)"
+ );
+ assert_eq!(
+ usage.cumulative_cache_read_tokens,
+ Some(50),
+ "cumulative still passes through from payload even on decrease"
+ );
+ }
+
+ #[test]
+ fn cache_read_explicit_zero_payload_after_explicit_zero_baseline_produces_some_zero_delta() {
+ // When both baseline and current are Some(0), turn_cache_read_tokens must
+ // be Some(0) — confirmed zero, not absent.
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-c4");
+ tracker.record("sess-c4", &payload_with_cache(1000, 200, Some(0)));
+ let _ = tracker.take();
+
+ tracker.begin_turn("sess-c4");
+ tracker.record("sess-c4", &payload_with_cache(1500, 300, Some(0)));
+ let usage = tracker.take().expect("pending");
+
+ assert!(usage.delta_reliable);
+ assert_eq!(
+ usage.turn_cache_read_tokens,
+ Some(0),
+ "explicit zero on both sides → Some(0), not None"
+ );
+ assert_eq!(usage.cumulative_cache_read_tokens, Some(0));
+ }
+
+ #[test]
+ fn cache_read_threads_through_setup_notification_baseline() {
+ // A setup notification (before begin_turn) with a nonzero cache count
+ // must update the committed baseline so the first real turn gets a
+ // correct delta from that starting point.
+ let mut tracker = UsageTracker::default();
+
+ // Setup notification: cumulative cache = 300.
+ tracker.record("sess-c5", &payload_with_cache(1000, 200, Some(300)));
+
+ tracker.begin_turn("sess-c5");
+ tracker.record("sess-c5", &payload_with_cache(1500, 350, Some(700)));
+ let usage = tracker.take().expect("pending");
+
+ assert!(usage.delta_reliable, "baseline from setup: reliable");
+ assert_eq!(
+ usage.turn_cache_read_tokens,
+ Some(400),
+ "cache delta from setup baseline: 700 - 300 = 400"
+ );
+ assert_eq!(usage.cumulative_cache_read_tokens, Some(700));
+ }
+
+ #[test]
+ fn cache_read_omitted_field_produces_none_cumulative_and_no_turn_delta() {
+ // A harness that omits accumulatedCachedInputTokens (e.g. goose) must
+ // produce None cumulative_cache_read_tokens — not Some(0) — and the
+ // turn delta must also be None even on the second turn.
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-c6");
+ // payload() uses None for accumulated_cached_input_tokens.
+ tracker.record("sess-c6", &payload(1000, 200, None));
+ let t1 = tracker.take().expect("turn 1");
+
+ assert!(
+ t1.cumulative_cache_read_tokens.is_none(),
+ "goose-shaped payload: cumulative must be None, not Some(0)"
+ );
+ assert!(
+ t1.turn_cache_read_tokens.is_none(),
+ "first turn always has no turn delta"
+ );
+
+ tracker.begin_turn("sess-c6");
+ tracker.record("sess-c6", &payload(1500, 300, None));
+ let t2 = tracker.take().expect("turn 2");
+
+ assert!(
+ t2.cumulative_cache_read_tokens.is_none(),
+ "continued goose session: cumulative must remain None"
+ );
+ assert!(
+ t2.turn_cache_read_tokens.is_none(),
+ "absent field on both sides → no turn delta invented"
+ );
+ assert!(
+ t2.delta_reliable,
+ "input/output reliability unaffected by absent cache field"
+ );
+ }
+
+ #[test]
+ fn cache_read_baseline_absent_then_present_produces_no_delta() {
+ // If the first turn omits the cache field (baseline stored as None) and
+ // the second turn reports a value, no delta can be computed — we have no
+ // baseline to subtract from. The cumulative value should still pass through.
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-c7");
+ tracker.record("sess-c7", &payload(1000, 200, None)); // no cache field
+ let _ = tracker.take();
+
+ tracker.begin_turn("sess-c7");
+ tracker.record("sess-c7", &payload_with_cache(1500, 300, Some(400)));
+ let usage = tracker.take().expect("turn 2");
+
+ assert!(
+ usage.turn_cache_read_tokens.is_none(),
+ "absent baseline → no turn delta even when current has a value"
+ );
+ assert_eq!(
+ usage.cumulative_cache_read_tokens,
+ Some(400),
+ "cumulative from current payload passes through"
+ );
+ assert!(usage.delta_reliable, "input/output reliability unaffected");
+ }
+
+ #[test]
+ fn cache_read_baseline_present_then_absent_produces_no_delta() {
+ // If the first turn reports the cache field but the second omits it
+ // (harness switched), no delta should be produced and cumulative is None.
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-c8");
+ tracker.record("sess-c8", &payload_with_cache(1000, 200, Some(300)));
+ let _ = tracker.take();
+
+ tracker.begin_turn("sess-c8");
+ tracker.record("sess-c8", &payload(1500, 300, None)); // no cache field
+ let usage = tracker.take().expect("turn 2");
+
+ assert!(
+ usage.turn_cache_read_tokens.is_none(),
+ "absent current → no turn delta"
+ );
+ assert!(
+ usage.cumulative_cache_read_tokens.is_none(),
+ "absent field: cumulative must be None"
+ );
+ assert!(usage.delta_reliable, "input/output reliability unaffected");
+ }
+
+ #[test]
+ fn pool_omitted_cache_field_publishes_no_cache_read_tokens_in_kind44200() {
+ // End-to-end: a buzz-agent or goose payload that omits the cache field
+ // must NOT publish cacheReadTokens in the kind:44200 event — neither
+ // in turn nor cumulative counts.
+ //
+ // This is the core acceptance test for Thufir's finding: the old code
+ // would publish cacheReadTokens: 0 for every harness regardless of
+ // whether the field was reported.
+ use crate::pool::build_turn_metric_counts;
+
+ let usage = TurnUsage {
+ session_id: "sess-pool-none".into(),
+ turn_seq: 2,
+ delta_reliable: true,
+ turn_input_tokens: Some(400),
+ turn_output_tokens: Some(100),
+ turn_total_tokens: None,
+ turn_cost_usd: None,
+ turn_cache_read_tokens: None,
+ cumulative_input_tokens: 700,
+ cumulative_output_tokens: 200,
+ cumulative_total_tokens: None,
+ cumulative_cost_usd: None,
+ cumulative_cache_read_tokens: None, // harness did not report the field
+ model: None,
+ };
+
+ let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage);
+
+ let turn = turn_counts.expect("turn counts must be present (delta reliable)");
+ assert!(
+ turn.cache_read_tokens.is_none(),
+ "omitted cache field: turn cacheReadTokens must be absent from kind:44200"
+ );
+
+ let cumulative = cumulative_counts.expect("cumulative counts always present");
+ assert!(
+ cumulative.cache_read_tokens.is_none(),
+ "omitted cache field: cumulative cacheReadTokens must be absent from kind:44200"
+ );
+ }
+
+ #[test]
+ fn pool_reported_cache_field_publishes_nonzero_cache_read_tokens_in_kind44200() {
+ // End-to-end: a buzz-agent payload with a nonzero cache count must
+ // publish cacheReadTokens in both turn and cumulative counts.
+ use crate::pool::build_turn_metric_counts;
+
+ let usage = TurnUsage {
+ session_id: "sess-pool-some".into(),
+ turn_seq: 2,
+ delta_reliable: true,
+ turn_input_tokens: Some(400),
+ turn_output_tokens: Some(100),
+ turn_total_tokens: None,
+ turn_cost_usd: None,
+ turn_cache_read_tokens: Some(300),
+ cumulative_input_tokens: 700,
+ cumulative_output_tokens: 200,
+ cumulative_total_tokens: None,
+ cumulative_cost_usd: None,
+ cumulative_cache_read_tokens: Some(600),
+ model: None,
+ };
+
+ let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage);
+
+ let turn = turn_counts.expect("turn counts present");
+ assert_eq!(
+ turn.cache_read_tokens,
+ Some(300),
+ "nonzero turn cache: must appear in kind:44200 turn counts"
+ );
+
+ let cumulative = cumulative_counts.expect("cumulative counts present");
+ assert_eq!(
+ cumulative.cache_read_tokens,
+ Some(600),
+ "nonzero cumulative cache: must appear in kind:44200 cumulative counts"
+ );
+ }
}
diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs
index 8e14fee195..054c334405 100644
--- a/crates/buzz-agent/src/agent.rs
+++ b/crates/buzz-agent/src/agent.rs
@@ -6,21 +6,49 @@ use tokio::task::JoinSet;
use crate::builtin;
use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES};
-use crate::handoff::HandoffOutcome;
+use crate::handoff::{ContextRecovery, HandoffOutcome};
use crate::hints::SkillEntry;
use crate::llm::Llm;
use crate::mcp::McpRegistry;
use crate::mcp::ResultBudget;
use crate::types::{
- AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult,
- ToolResultContent, TurnTotalState,
+ AgentError, ContentBlock, HistoryItem, ProviderStop, SessionUsageBaseline, StopReason,
+ ToolCall, ToolResult, ToolResultContent, TurnTotalState,
};
use crate::wire::{self, WireSender};
const ERROR_REFLECTION_SUFFIX: &str =
"\n\n[Reflect] Before retrying, identify the cause and change your approach.";
+const UNSUPPORTED_IMAGE_TOOL_MESSAGE: &str = "The current model does not support image input. The image was removed from conversation history so this turn can continue. Use a text-based inspection tool or ask the user for a textual description instead.";
+
+/// Remove image blocks that the provider has explicitly rejected while keeping
+/// their surrounding tool result (and therefore the tool-call/result pairing)
+/// intact. Returns the number of images removed; zero means the provider error
+/// cannot be safely recovered by mutating history.
+fn replace_unsupported_images(history: &mut [HistoryItem]) -> usize {
+ let mut replaced = 0;
+ for item in history {
+ let HistoryItem::ToolResult(result) = item else {
+ continue;
+ };
+ let before = result.content.len();
+ result
+ .content
+ .retain(|content| !matches!(content, ToolResultContent::Image { .. }));
+ let removed = before - result.content.len();
+ if removed > 0 {
+ replaced += removed;
+ result.is_error = true;
+ result.content.push(ToolResultContent::Text(
+ UNSUPPORTED_IMAGE_TOOL_MESSAGE.to_string(),
+ ));
+ }
+ }
+ replaced
+}
+
/// Maximum reply reminders emitted per prompt when `require_reply` is on.
///
/// After this many, the turn is allowed to end whether or not anything was
@@ -150,9 +178,40 @@ pub struct RunCtx<'a> {
/// Reset to `Unseen` at turn start in `run()`. Callers must not derive a
/// total by summing input+output — that is the UI display approximation only.
pub turn_total_state: &'a mut TurnTotalState,
+ /// Session-cumulative counters as they stood when this turn began. Added to
+ /// the `turn_*` accumulators above to report a cumulative figure mid-turn;
+ /// the session's own copy is only advanced once, after the turn returns.
+ pub usage_baseline: SessionUsageBaseline,
}
impl RunCtx<'_> {
+ /// Send a session-cumulative `usage_update` reflecting everything observed
+ /// up to and including the most recent LLM response.
+ ///
+ /// The figure is the turn-start baseline plus this turn's running
+ /// accumulators, which is exactly what `session/prompt` will fold into the
+ /// session once the turn returns — so a mid-turn notification and the
+ /// end-of-turn one agree, and a turn that never returns has still reported
+ /// everything but its final in-flight request.
+ async fn emit_usage_update(&self) {
+ let base = self.usage_baseline;
+ let payload = wire::usage_update_payload(
+ base.input_tokens
+ .saturating_add(self.turn_input_tokens.unwrap_or(0)),
+ base.output_tokens
+ .saturating_add(self.turn_output_tokens.unwrap_or(0)),
+ base.cached_input_tokens
+ .saturating_add(self.turn_cached_input_tokens.unwrap_or(0)),
+ base.total_state.merge_session(*self.turn_total_state),
+ self.effective_model,
+ );
+ wire::send(
+ self.wire,
+ wire::goose_session_update(self.session_id, payload),
+ )
+ .await;
+ }
+
pub async fn run(&mut self, prompt: Vec) -> Result {
let user_text = prompt_to_text(prompt)?;
if user_text.len() > MAX_PROMPT_BYTES {
@@ -170,6 +229,14 @@ impl RunCtx<'_> {
*self.turn_output_tokens = None;
*self.turn_cached_input_tokens = None;
*self.turn_total_state = TurnTotalState::Unseen;
+ // Per-turn handoff-attempt counter. Scoped here (not persisted in the
+ // session) so `BUZZ_AGENT_MAX_HANDOFFS` bounds compactions per
+ // `session/prompt` turn rather than per session lifetime. A
+ // long-lived session legitimately needs unbounded handoffs across
+ // prompts; the cap only exists to stop runaway within a single turn.
+ // The session-cumulative `handoff_count` (used in log lines) is not
+ // reset: it reflects total compactions since session start.
+ let mut handoff_attempts: usize = 0;
let mut round = 0u32;
// Per-prompt `_Stop` objection count. Bounded per prompt (not per
@@ -184,6 +251,10 @@ impl RunCtx<'_> {
// successful publish. See `is_buzz_reply_call`.
let mut buzz_reply_call_seen = false;
let mut reply_nags = 0u32;
+ // Per-`run()` reactive context-recovery budget. Per-turn, not
+ // per-session: a fresh prompt deserves a fresh chance to recover, and
+ // `max_rounds` defaults to 0 (unbounded) so it cannot bound this.
+ let mut context_recoveries = 0u32;
loop {
if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds {
return Ok(StopReason::MaxTurnRequests);
@@ -196,7 +267,7 @@ impl RunCtx<'_> {
// its next request — the turn continues, it is not restarted. Drain
// non-blocking; an empty queue is the common case.
self.drain_steers();
- match self.maybe_handoff().await {
+ match self.maybe_handoff(&mut handoff_attempts).await {
HandoffOutcome::Cancelled => return Ok(StopReason::Cancelled),
// Context was just reset — the prior request's token count no
// longer describes the (now much smaller) history. Clear both
@@ -218,10 +289,10 @@ impl RunCtx<'_> {
tools.push(builtin::load_skill_def());
}
round = round.saturating_add(1);
- let response = tokio::select! {
+ let response_result = tokio::select! {
biased;
_ = self.cancel.changed() => return Ok(StopReason::Cancelled),
- r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r?,
+ r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r,
_ = async {
// Keepalive ticker: emit a lightweight session update every 30s
// while waiting on the LLM provider. This resets the ACP harness
@@ -244,7 +315,78 @@ impl RunCtx<'_> {
}
} => unreachable!(),
};
-
+ let response = match response_result {
+ Ok(response) => response,
+ Err(AgentError::UnsupportedImageInput(detail)) => {
+ let removed = replace_unsupported_images(self.history);
+ if removed == 0 {
+ return Err(AgentError::UnsupportedImageInput(detail));
+ }
+ tracing::warn!(
+ model = self.effective_model,
+ removed_images = removed,
+ "provider rejected image input; removed images from history and continuing turn"
+ );
+ continue;
+ }
+ // Reactive context recovery. A context-window 400 is the only
+ // ground-truth signal that history must shrink, and it arrives
+ // exactly when the proactive gate cannot act: a failed request
+ // reports no usage, so `last_request_input_tokens` stays frozen
+ // at the last SUCCESSFUL (sub-threshold) reading and
+ // `should_handoff()` returns false forever. Without this arm the
+ // error propagates out of `run()`, the in-memory session keeps
+ // the same oversized history, and every later prompt in that
+ // session fails the same way — a stick that persists across
+ // turns for the life of the session. (Restarting the agent DOES
+ // clear it: history lives only in the in-memory session map, so
+ // a restart is the manual workaround, not an exception to it.)
+ //
+ // Retried in-loop rather than returned so the recovered context
+ // continues the turn the user is waiting on.
+ Err(AgentError::LlmContextExceeded(e)) => {
+ match self
+ .recover_from_context_overflow(&mut context_recoveries)
+ .await
+ {
+ ContextRecovery::Recovered => {
+ // Refund the round the rejected request consumed.
+ // `round` is incremented before `complete()`, so
+ // without this a finite `max_rounds` is spent by a
+ // request the provider refused to serve: the loop
+ // would re-enter, hit the cap at the top, and return
+ // `MaxTurnRequests` having destroyed history and
+ // never asked the model again — a worse outcome than
+ // the error it replaced.
+ //
+ // This cannot become an unbounded amnesty: refunds
+ // happen only on a *successful* recovery, and
+ // recoveries are independently capped by
+ // `MAX_CONTEXT_RECOVERIES_PER_RUN`, so at most that
+ // many rounds can ever be refunded in one turn. An
+ // ordinary round is never refunded.
+ round = round.saturating_sub(1);
+ // Same reset as the proactive path (see
+ // `HandoffOutcome::Performed` above): the frozen
+ // token reading describes history that no longer
+ // exists. Clearing it is what lets the gate work
+ // again on later rounds.
+ *self.last_request_input_tokens = None;
+ *self.last_request_history_bytes = None;
+ continue;
+ }
+ ContextRecovery::Cancelled => return Ok(StopReason::Cancelled),
+ // No rescue left. Surface the provider's own error
+ // rather than a synthetic one: it names the model and
+ // the offending sizes, and a visible failure is the
+ // point — the alternative is retrying forever.
+ ContextRecovery::Exhausted => {
+ return Err(AgentError::LlmContextExceeded(e))
+ }
+ }
+ }
+ Err(error) => return Err(error),
+ };
// Record provider-reported input usage so the next loop iteration's
// handoff gate can compare it against the token budget. We capture
// it together with the history byte size AT THIS MOMENT — which is
@@ -299,6 +441,23 @@ impl RunCtx<'_> {
// this gate rather than representing absent categories as zero.
if response.input_tokens.is_some() || response.output_tokens.is_some() {
*self.turn_total_state = self.turn_total_state.fold(response.total_tokens);
+ // Report what the turn has burned SO FAR, before running the
+ // next round. A turn is many provider round-trips over many
+ // minutes, and until this point the only report was the one
+ // `session/prompt` sends after the turn returns — so a turn
+ // that was cancelled, timed out, or whose process was killed
+ // reported nothing at all, and its tokens (already billed)
+ // existed only in this stack frame. Reporting per round bounds
+ // the loss to the single request in flight.
+ //
+ // Emitting more than one `usage_update` per turn is expected by
+ // the consumer: buzz-acp's UsageTracker advances its committed
+ // baseline only when the turn's metric is published, so every
+ // notification within a turn measures from the same frozen
+ // baseline and the last one seen is the turn's true total.
+ // goose behaves the same way, which is why the tracker was
+ // written to tolerate it.
+ self.emit_usage_update().await;
}
if !response.reasoning.is_empty() {
@@ -897,6 +1056,66 @@ mod tests {
use super::*;
use serde_json::json;
+ /// `truncate_history` cannot serve as the context-window fallback: it is
+ /// measured in BYTES (`max_history_bytes`, default 16 MiB, a request-body
+ /// limiter) while the thing the fallback must defend is a TOKEN window
+ /// (`max_context_tokens`, default 200k). A history large enough to blow a
+ /// 200k-token window is nowhere near 16 MiB, so at the default budget the
+ /// fallback evicts nothing at all — which is why the `Skipped ->
+ /// truncate_history` path left the agent permanently stuck and the reactive
+ /// ladder had to be built instead.
+ ///
+ /// The negative assertion is paired with a positive control (same helper,
+ /// same fixture, budget set to the window instead) so that "evicted
+ /// nothing" is a real observation about the unit mismatch rather than a
+ /// blind probe that could never evict.
+ #[test]
+ fn truncate_history_is_a_noop_at_context_window_scale() {
+ // ~800 KB of history. At any real bytes/token density (densest real
+ // content is ~1.4 B/tok, typical prose ~3-4) this is >= 200k tokens,
+ // i.e. already over a 200k window.
+ let mut history: Vec = Vec::new();
+ for i in 0..400 {
+ history.push(HistoryItem::User(format!("q{i} {}", "x".repeat(1000))));
+ history.push(HistoryItem::Assistant {
+ text: format!("a{i} {}", "y".repeat(1000)),
+ tool_calls: vec![],
+ reasoning_details: None,
+ });
+ }
+ let total: usize = history.iter().map(HistoryItem::estimated_bytes).sum();
+ let pressure: usize = history
+ .iter()
+ .map(HistoryItem::context_pressure_bytes)
+ .sum();
+ assert!(
+ total > 800_000,
+ "fixture must be big enough to exceed a 200k-token window, got {total}"
+ );
+
+ // NEGATIVE: the real configured default budget.
+ let default_budget = 16 * 1024 * 1024;
+ let mut under_default = history.clone();
+ truncate_history(&mut under_default, default_budget);
+ assert_eq!(
+ under_default.len(),
+ history.len(),
+ "16 MiB byte budget evicted nothing from a {total}-byte history \
+ (pressure {pressure}) that already exceeds a 200k-token window"
+ );
+
+ // POSITIVE CONTROL: same helper, same fixture, budget set to the
+ // window instead. If this also evicted nothing the assertion above
+ // would prove nothing about the unit mismatch -- it would just mean
+ // the probe is blind.
+ let mut under_window = history.clone();
+ truncate_history(&mut under_window, 200_000);
+ assert!(
+ under_window.len() < history.len(),
+ "positive control must evict: probe is blind otherwise"
+ );
+ }
+
/// The shapes the guard must recognize as a publish attempt. Callers apply
/// the registry checks first; these cover the name suffix and command text.
#[test]
@@ -1027,6 +1246,47 @@ mod tests {
assert!(total_after <= max_bytes);
}
+ #[test]
+ fn unsupported_images_become_recoverable_tool_errors() {
+ let mut history = vec![
+ HistoryItem::Assistant {
+ text: String::new(),
+ tool_calls: vec![ToolCall {
+ provider_id: "call-image".into(),
+ name: "dev__view_image".into(),
+ arguments: json!({ "source": "spec.png" }),
+ provider_extra: Default::default(),
+ }],
+ reasoning_details: None,
+ },
+ HistoryItem::ToolResult(ToolResult {
+ provider_id: "call-image".into(),
+ content: vec![
+ ToolResultContent::Text("10x10 image from spec.png".into()),
+ ToolResultContent::Image {
+ data: "aW1n".into(),
+ mime_type: "image/png".into(),
+ },
+ ],
+ is_error: false,
+ }),
+ ];
+
+ assert_eq!(replace_unsupported_images(&mut history), 1);
+ let HistoryItem::ToolResult(result) = &history[1] else {
+ panic!("tool result must stay paired with the assistant tool call");
+ };
+ assert_eq!(result.provider_id, "call-image");
+ assert!(result.is_error);
+ assert!(result
+ .content
+ .iter()
+ .all(|content| !matches!(content, ToolResultContent::Image { .. })));
+ assert!(result.text().contains("does not support image input"));
+ assert!(result.text().contains("10x10 image from spec.png"));
+ assert_eq!(replace_unsupported_images(&mut history), 0);
+ }
+
#[test]
fn truncate_history_noop_when_under_budget() {
let mut history = vec![
diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs
index aa2a121c99..0aaa2da7ea 100644
--- a/crates/buzz-agent/src/catalog.rs
+++ b/crates/buzz-agent/src/catalog.rs
@@ -7,12 +7,17 @@
//!
//! - Static bearer (`DATABRICKS_TOKEN`): returned immediately.
//! - PKCE cache hit: returned from disk without a network round-trip.
-//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)` — the
-//! caller degrades gracefully; no browser, no hang.
+//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)`.
+//!
+//! This helper never opens a browser. Callers choose whether to reject, degrade,
+//! or start a separate interactive authentication flow.
+
+use std::sync::Arc;
use reqwest::Client;
use crate::{
+ auth::TokenSource,
config::{Config, Provider},
llm::build_token_source,
types::AgentError,
@@ -26,57 +31,22 @@ pub struct ModelEntry {
pub name: String,
}
-/// Known Databricks AI Gateway v2 models — used as a fallback when the
-/// `api/ai-gateway/v2/endpoints` call returns an empty list.
+/// Known Databricks AI Gateway v2 models — used only when an authenticated
+/// `api/ai-gateway/v2/endpoints` call succeeds with an empty list.
/// Mirrors goose's `DATABRICKS_V2_KNOWN_MODELS`.
pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] =
&["databricks-gpt-5-5", "databricks-claude-opus-4-7"];
-/// Returns the discovery-failure fallback catalog for a Databricks provider.
-///
-/// This is the list of models advertised by `session/new` when
-/// `discover_databricks_models` returns an error (e.g., no token available).
-///
-/// - `DatabricksV2` falls back to the configured model plus
-/// [`DATABRICKS_V2_KNOWN_MODELS`] so the model-picker is always populated for
-/// AI Gateway v2 users. The configured model leads: without it a fallback
-/// catalog can omit the very model the agent is running, leaving the picker
-/// unable to represent the current selection.
-/// - Legacy `Databricks` falls back to only the configured model — the
-/// `DATABRICKS_V2_KNOWN_MODELS` IDs are AI Gateway v2 endpoints that the
-/// `/serving-endpoints/{model}/invocations` API may not serve.
-///
-/// Extracting this as a pure function makes the split testable without
-/// spawning an async runtime or making network calls.
-pub fn discovery_failure_fallback(provider: Provider, configured_model: &str) -> Vec {
- // `resolve_model` does not trim, so a padded `DATABRICKS_MODEL` reaches here:
- // normalize once, or the dedupe below misses and the picker lists the model
- // twice (once padded, once from the known slate).
- let configured_model = configured_model.trim();
- let configured = ModelEntry {
- id: configured_model.to_string(),
- name: configured_model.to_string(),
- };
- match provider {
- Provider::DatabricksV2 => {
- let mut entries = Vec::with_capacity(DATABRICKS_V2_KNOWN_MODELS.len() + 1);
- if !configured_model.is_empty() {
- entries.push(configured);
- }
- entries.extend(
- DATABRICKS_V2_KNOWN_MODELS
- .iter()
- .filter(|id| **id != configured_model)
- .map(|id| ModelEntry {
- id: id.to_string(),
- name: id.to_string(),
- }),
- );
- entries
- }
- Provider::Databricks => vec![configured],
- _ => vec![configured],
- }
+const AUTHENTICATED_EMPTY_CATALOG_SUFFIX: &str = " (default catalog)";
+
+fn authenticated_empty_v2_catalog() -> Vec {
+ DATABRICKS_V2_KNOWN_MODELS
+ .iter()
+ .map(|id| ModelEntry {
+ id: id.to_string(),
+ name: format!("{id}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}"),
+ })
+ .collect()
}
/// Heuristic: `true` when a v2 AI Gateway endpoint name looks like it serves
@@ -109,23 +79,47 @@ pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool {
///
/// Returns a non-empty `Vec` on success. Returns
/// `Err(AgentError::LlmAuth)` when no token is available (no static token,
-/// no PKCE cache) — callers should degrade gracefully rather than hanging.
+/// no PKCE cache). The helper itself never starts interactive authentication.
///
/// # Panics
/// Never panics.
pub async fn discover_databricks_models(cfg: &Config) -> Result, AgentError> {
- let token_source = build_token_source(cfg)?;
- let bearer = token_source.bearer_no_browser().await?;
+ discover_databricks_models_with_token_source(cfg, build_token_source(cfg)?).await
+}
+async fn discover_databricks_models_with_token_source(
+ cfg: &Config,
+ token_source: Arc,
+) -> Result, AgentError> {
+ let mut bearer = token_source.bearer_no_browser().await?;
let http = Client::new();
let host = cfg.base_url.trim_end_matches('/');
+ let mut refreshed = false;
+
+ loop {
+ let result = match cfg.provider {
+ Provider::Databricks => fetch_v1_models(&http, host, &bearer).await,
+ Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await,
+ _ => {
+ return Err(AgentError::InvalidParams(
+ "discover_databricks_models called for non-Databricks provider".into(),
+ ));
+ }
+ };
- match cfg.provider {
- Provider::Databricks => fetch_v1_models(&http, host, &bearer).await,
- Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await,
- _ => Err(AgentError::InvalidParams(
- "discover_databricks_models called for non-Databricks provider".into(),
- )),
+ match result {
+ Err(AgentError::LlmAuth(_)) if !refreshed => {
+ refreshed = true;
+ let fresh = token_source.refresh_now(&bearer).await?;
+ if fresh == bearer {
+ return Err(AgentError::LlmAuth(
+ "Databricks rejected the configured credential".into(),
+ ));
+ }
+ bearer = fresh;
+ }
+ result => return result,
+ }
}
}
@@ -149,6 +143,11 @@ async fn fetch_v1_models(
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
+ if status.as_u16() == 401 {
+ return Err(AgentError::LlmAuth(format!(
+ "Databricks model discovery HTTP {status}"
+ )));
+ }
return Err(AgentError::Llm(format!(
"Databricks model discovery HTTP {status}: {body}"
)));
@@ -264,6 +263,11 @@ async fn fetch_v2_models(
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
+ if status.as_u16() == 401 {
+ return Err(AgentError::LlmAuth(format!(
+ "Databricks v2 model discovery HTTP {status}"
+ )));
+ }
return Err(AgentError::Llm(format!(
"Databricks v2 model discovery HTTP {status}: {body}"
)));
@@ -286,13 +290,7 @@ async fn fetch_v2_models(
// Fall back to known-model list if the API returned nothing.
if all_endpoints.is_empty() {
- return Ok(DATABRICKS_V2_KNOWN_MODELS
- .iter()
- .map(|id| ModelEntry {
- id: id.to_string(),
- name: id.to_string(),
- })
- .collect());
+ return Ok(authenticated_empty_v2_catalog());
}
sort_v2_endpoints_newest_first(&mut all_endpoints);
@@ -396,6 +394,77 @@ pub(crate) fn parse_v2_endpoints_page(
#[cfg(test)]
mod tests {
use super::*;
+ use async_trait::async_trait;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ struct RefreshingTestTokenSource {
+ refreshes: AtomicUsize,
+ }
+
+ #[async_trait]
+ impl TokenSource for RefreshingTestTokenSource {
+ async fn bearer(&self) -> Result {
+ Ok("rejected".into())
+ }
+
+ async fn refresh_now(&self, rejected: &str) -> Result {
+ assert_eq!(rejected, "rejected");
+ self.refreshes.fetch_add(1, Ordering::SeqCst);
+ Ok("fresh".into())
+ }
+ }
+
+ #[tokio::test]
+ async fn discovery_refreshes_rejected_bearer_once_then_retries_successfully() {
+ use axum::{
+ extract::Query,
+ http::{HeaderMap, StatusCode},
+ routing::get,
+ Json, Router,
+ };
+ use std::collections::HashMap;
+
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let host = format!("http://{}", listener.local_addr().unwrap());
+ let requests = Arc::new(AtomicUsize::new(0));
+ let requests_for_route = requests.clone();
+ let app = Router::new().route(
+ "/api/ai-gateway/v2/endpoints",
+ get(
+ move |headers: HeaderMap, Query(_query): Query>| {
+ let requests = requests_for_route.clone();
+ async move {
+ requests.fetch_add(1, Ordering::SeqCst);
+ match headers
+ .get("authorization")
+ .and_then(|value| value.to_str().ok())
+ {
+ Some("Bearer fresh") => Ok(Json(serde_json::json!({
+ "endpoints": [{"name": "discovered-model"}],
+ "next_page_token": null,
+ }))),
+ _ => Err((StatusCode::UNAUTHORIZED, "rejected")),
+ }
+ }
+ },
+ ),
+ );
+ tokio::spawn(async move {
+ let _ = axum::serve(listener, app).await;
+ });
+
+ let source = Arc::new(RefreshingTestTokenSource {
+ refreshes: AtomicUsize::new(0),
+ });
+ let cfg = Config::for_discovery(Provider::DatabricksV2, String::new(), host);
+ let models = discover_databricks_models_with_token_source(&cfg, source.clone())
+ .await
+ .unwrap();
+
+ assert_eq!(models[0].id, "discovered-model");
+ assert_eq!(source.refreshes.load(Ordering::SeqCst), 1);
+ assert_eq!(requests.load(Ordering::SeqCst), 2);
+ }
#[test]
fn v1_parse_filters_ready_chat_endpoints() {
@@ -574,6 +643,17 @@ mod tests {
);
}
+ #[test]
+ fn authenticated_empty_v2_catalog_marks_fallback_provenance() {
+ let models = authenticated_empty_v2_catalog();
+ let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect();
+
+ assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS);
+ assert!(models.iter().all(|model| {
+ model.name == format!("{}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}", model.id)
+ }));
+ }
+
#[test]
fn is_chat_capable_endpoint_keeps_unrecognised_names() {
// Prefer including over silently dropping — an unknown family is kept.
@@ -585,47 +665,4 @@ mod tests {
assert!(!is_chat_capable_endpoint("databricks-gte-large-en"));
assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b"));
}
-
- #[test]
- fn v2_discovery_failure_fallback_leads_with_configured_model() {
- let result = discovery_failure_fallback(Provider::DatabricksV2, "databricks-claude-opus-5");
- let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
-
- // The running model must be representable in the picker even when
- // discovery failed, so it leads the fallback catalog.
- assert_eq!(ids.first(), Some(&"databricks-claude-opus-5"));
- for known in DATABRICKS_V2_KNOWN_MODELS {
- assert!(ids.contains(known), "fallback must retain '{known}'");
- }
- }
-
- #[test]
- fn v2_discovery_failure_fallback_does_not_duplicate_configured_model() {
- let configured = DATABRICKS_V2_KNOWN_MODELS[0];
- let result = discovery_failure_fallback(Provider::DatabricksV2, configured);
- let occurrences = result.iter().filter(|m| m.id == configured).count();
- assert_eq!(occurrences, 1, "got: {result:?}");
- assert_eq!(result.len(), DATABRICKS_V2_KNOWN_MODELS.len());
- }
-
- #[test]
- fn v2_discovery_failure_fallback_tolerates_blank_configured_model() {
- for configured in ["", " "] {
- let result = discovery_failure_fallback(Provider::DatabricksV2, configured);
- let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
- assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec());
- }
- }
-
- #[test]
- fn v2_discovery_failure_fallback_dedupes_a_padded_configured_model() {
- // `DATABRICKS_MODEL=" databricks-gpt-5-5 "` reaches here untrimmed, and an
- // untrimmed comparison would list the model twice — once padded, once from
- // the known slate.
- let configured = DATABRICKS_V2_KNOWN_MODELS[0];
- let result =
- discovery_failure_fallback(Provider::DatabricksV2, &format!(" {configured} "));
- let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
- assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec());
- }
}
diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs
index afbda5379d..439e49f4e5 100644
--- a/crates/buzz-agent/src/config.rs
+++ b/crates/buzz-agent/src/config.rs
@@ -657,6 +657,21 @@ pub const HANDOFF_ORIGINAL_TASK_MAX_BYTES: usize = 16 * 1024;
pub const HANDOFF_MAX_TOOL_NAMES: usize = 20;
+/// Maximum reactive context-recovery attempts per `run()`. A provider
+/// context-window 400 is recoverable — shrink history and retry — but the
+/// retry must be bounded: `max_rounds` defaults to `0` (unbounded), so without
+/// its own budget a request that stays oversized after every rescue would
+/// retry forever. On exhaustion the error surfaces to the caller, which is a
+/// visible failure rather than a silent infinite rescue.
+pub const MAX_CONTEXT_RECOVERIES_PER_RUN: u32 = 3;
+
+/// Floor for the reactive handoff's history-prompt budget, in bytes. Each
+/// recovery attempt halves the budget so the rescue summarize call can escape
+/// an overstated `max_context_tokens`, but halving must terminate: below this
+/// the prompt can no longer carry a useful summary, so the recovery gives up
+/// and surfaces the error instead of issuing ever-smaller doomed requests.
+pub const HANDOFF_MIN_PROMPT_BUDGET_BYTES: usize = 4 * 1024;
+
const DEFAULT_SYSTEM_PROMPT: &str =
"You are buzz-agent. Use the provided tools to act. Tool calls are your only output.";
@@ -714,6 +729,11 @@ pub struct Config {
/// operators lower/raise it for other models. Set via
/// `BUZZ_AGENT_MAX_CONTEXT_TOKENS`.
pub max_context_tokens: u64,
+ /// Maximum context-handoff attempts permitted within a single
+ /// `session/prompt` turn. Caps runaway compaction loops inside one turn;
+ /// does NOT limit handoffs across a session's lifetime — a long-lived
+ /// session can compact on every successive turn without hitting this bound.
+ /// Set via `BUZZ_AGENT_MAX_HANDOFFS`. Default 10.
pub max_handoffs: usize,
pub max_parallel_tools: usize,
pub hook_timeout: Duration,
diff --git a/crates/buzz-agent/src/handoff.rs b/crates/buzz-agent/src/handoff.rs
index 3b0feefecf..5fdbc3079d 100644
--- a/crates/buzz-agent/src/handoff.rs
+++ b/crates/buzz-agent/src/handoff.rs
@@ -1,6 +1,7 @@
use crate::agent::RunCtx;
use crate::config::{
- HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES,
+ HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_MIN_PROMPT_BUDGET_BYTES,
+ HANDOFF_ORIGINAL_TASK_MAX_BYTES, MAX_CONTEXT_RECOVERIES_PER_RUN,
};
use crate::types::HistoryItem;
@@ -22,24 +23,147 @@ pub(crate) enum HandoffOutcome {
Cancelled,
}
+/// Result of the reactive context-recovery ladder.
+pub(crate) enum ContextRecovery {
+ /// History was reset; the caller should retry the request.
+ Recovered,
+ /// Cancelled mid-recovery.
+ Cancelled,
+ /// No rescue remains — the caller must surface the provider error. Either
+ /// the per-`run()` budget is spent or the prompt budget fell below the
+ /// floor where a summary can still be useful.
+ Exhausted,
+}
+
const HANDOFF_SYSTEM_PROMPT: &str = "You are generating a context handoff summary for the next \
turn of an autonomous agent. Be concise but thorough. Cover: what the original task was, what \
you accomplished, key decisions made, what remains, and one concrete next step. Output plain \
text only — no tool calls, no JSON. Stay under 8192 tokens.";
impl RunCtx<'_> {
- pub(crate) async fn maybe_handoff(&mut self) -> HandoffOutcome {
+ pub(crate) async fn maybe_handoff(&mut self, handoff_attempts: &mut usize) -> HandoffOutcome {
if !self.should_handoff() {
return HandoffOutcome::Skipped;
}
- if *self.handoff_count >= self.cfg.max_handoffs {
- tracing::info!(
- "handoff cap reached ({}); using truncation",
- self.cfg.max_handoffs
+ if *handoff_attempts >= self.cfg.max_handoffs {
+ let projected = self.projected_handoff_input_tokens();
+ let threshold =
+ token_threshold(self.cfg.max_context_tokens, self.cfg.max_output_tokens);
+ tracing::warn!(
+ session_id = self.session_id,
+ reason = "preflight",
+ handoff_attempts = *handoff_attempts,
+ max_handoffs = self.cfg.max_handoffs,
+ projected_tokens = projected,
+ threshold_tokens = threshold,
+ "handoff cap reached; using truncation",
);
return HandoffOutcome::Skipped;
}
- let prompt = self.build_handoff_prompt();
+ // Consume one attempt slot before calling handoff(). This ensures
+ // that empty-summary, summarize-error, and cancellation outcomes all
+ // burn budget — not just successful compactions — so the cap cannot
+ // be bypassed by a flaky summarizer.
+ *handoff_attempts += 1;
+ self.handoff(None).await
+ }
+
+ /// Handoff forced by a provider context-window rejection, bypassing both
+ /// gates in [`Self::maybe_handoff`].
+ ///
+ /// The gates exist to *predict* overflow; a 400 naming a context-length
+ /// overflow is overflow already observed, so neither prediction applies.
+ /// `should_handoff()` reads a token count frozen at the last SUCCESSFUL
+ /// request (a failed request reports no usage), so it is under threshold by
+ /// construction — that frozen reading is the permanent stick. And
+ /// `max_handoffs` is a cost cap whose only alternative here is a request
+ /// that cannot succeed.
+ ///
+ /// `history_budget_bytes` is explicit rather than derived from
+ /// `cfg.max_context_tokens`: that window is the quantity the provider just
+ /// contradicted, so the recovery ladder must not be computed from it.
+ pub(crate) async fn forced_handoff(&mut self, history_budget_bytes: usize) -> HandoffOutcome {
+ tracing::warn!(
+ "provider reported context overflow; forcing handoff (history budget {history_budget_bytes} bytes)"
+ );
+ self.handoff(Some(history_budget_bytes)).await
+ }
+
+ /// The reactive context-recovery ladder, run after the provider rejected a
+ /// request with a context-window 400.
+ ///
+ /// `attempts` is the caller's per-`run()` recovery counter, advanced here as
+ /// rungs are consumed. The caller owns it so the budget spans every
+ /// context-400 in the turn, not just the rungs of one ladder.
+ ///
+ /// The shrink schedule is anchored on the history that was just *observed*
+ /// to be too large, halving from there — not on `cfg.max_context_tokens`,
+ /// which the provider just contradicted and which may be overstated by an
+ /// unknown factor. Halving needs no calibration: by the third rung it is at
+ /// 1/8 of the rejected size.
+ ///
+ /// Loops rather than returning after one rung because the summarize call
+ /// travels the same provider path and can be rejected for the same reason.
+ /// Treating that as unrecoverable would reproduce the very stick this fixes:
+ /// the next rung halves the summarizer's own prompt, which is the only way
+ /// out.
+ ///
+ /// Gives up when the next budget would fall below
+ /// [`HANDOFF_MIN_PROMPT_BUDGET_BYTES`]. That can happen on the FIRST rung
+ /// when history is already small — correct, not premature: if a few KiB of
+ /// history still overflows the window, the overflow is dominated by what a
+ /// handoff cannot shrink (system prompt, tool schemas, the live user
+ /// prompt), so further halving would only issue smaller doomed requests in
+ /// place of a clear error.
+ pub(crate) async fn recover_from_context_overflow(
+ &mut self,
+ attempts: &mut u32,
+ ) -> ContextRecovery {
+ let rejected_bytes: usize = self
+ .history
+ .iter()
+ .map(HistoryItem::context_pressure_bytes)
+ .sum();
+ loop {
+ if *attempts >= MAX_CONTEXT_RECOVERIES_PER_RUN {
+ tracing::error!(
+ "context recovery budget spent ({MAX_CONTEXT_RECOVERIES_PER_RUN} attempts this turn); surfacing provider error"
+ );
+ return ContextRecovery::Exhausted;
+ }
+ // Shift by `attempts + 1`: the first rung already halves, since
+ // rebuilding the rejected size would just fail again.
+ let shift = (*attempts + 1).min(usize::BITS - 1);
+ let budget = rejected_bytes >> shift;
+ *attempts += 1;
+ if budget < HANDOFF_MIN_PROMPT_BUDGET_BYTES {
+ tracing::error!(
+ "context recovery would shrink the handoff prompt to {budget} bytes, below \
+ the {HANDOFF_MIN_PROMPT_BUDGET_BYTES}-byte floor (history {rejected_bytes} \
+ bytes); surfacing provider error"
+ );
+ return ContextRecovery::Exhausted;
+ }
+ match self.forced_handoff(budget).await {
+ HandoffOutcome::Performed => return ContextRecovery::Recovered,
+ HandoffOutcome::Cancelled => return ContextRecovery::Cancelled,
+ // Summarizer errored or returned nothing — possibly because its
+ // own prompt overflowed. Truncation is not a usable fallback
+ // (it sizes against the request-body budget, not context
+ // pressure), so take the next rung with a smaller prompt.
+ HandoffOutcome::Skipped => {
+ tracing::warn!(
+ "forced handoff at {budget} bytes did not run; shrinking further"
+ )
+ }
+ }
+ }
+ }
+
+ /// The handoff mechanism itself: summarize, reset, re-seat the live prompt.
+ /// Holds no gate — callers decide whether a handoff is warranted.
+ async fn handoff(&mut self, history_budget_bytes: Option) -> HandoffOutcome {
+ let prompt = self.build_handoff_prompt(history_budget_bytes);
let tokens_before = self.projected_handoff_input_tokens();
let summary = tokio::select! {
biased;
@@ -164,7 +288,10 @@ impl RunCtx<'_> {
}
}
- fn build_handoff_prompt(&self) -> String {
+ /// Build the summarizer prompt. `history_budget_bytes` overrides the
+ /// budget normally derived from `cfg.max_context_tokens`; `None` keeps the
+ /// derived value, which is what the proactive path uses.
+ fn build_handoff_prompt(&self, history_budget_bytes: Option) -> String {
let mut head = String::new();
head.push_str(&format!(
"[Internal handoff #{} — context reset]\n\n",
@@ -192,11 +319,22 @@ impl RunCtx<'_> {
(2) what was accomplished, (3) key decisions, (4) what remains, \
(5) one concrete next step. Be concise but thorough. Plain text.\n";
let history_header = "\n# Session History (oldest first)\n";
- let prompt_budget = handoff_prompt_budget_bytes(
- self.cfg.max_context_tokens,
- HANDOFF_MAX_OUTPUT_TOKENS,
- head.len() + history_header.len() + tail.len(),
- );
+ let fixed_bytes = head.len() + history_header.len() + tail.len();
+ // An explicit budget is the allowance for the whole prompt, so subtract
+ // the fixed frame from it exactly as the derived path does — otherwise
+ // a caller's ceiling would be silently exceeded by the frame. When the
+ // frame alone is larger than the budget, history drops to zero and the
+ // frame is what remains: it is already independently clamped
+ // (`HANDOFF_ORIGINAL_TASK_MAX_BYTES`, `HANDOFF_MAX_TOOL_NAMES`) and is
+ // not reducible from here.
+ let prompt_budget = match history_budget_bytes {
+ Some(explicit) => explicit.saturating_sub(fixed_bytes),
+ None => handoff_prompt_budget_bytes(
+ self.cfg.max_context_tokens,
+ HANDOFF_MAX_OUTPUT_TOKENS,
+ fixed_bytes,
+ ),
+ };
let mut snippets: Vec = Vec::new();
let mut snippets_bytes = 0usize;
diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs
index 9a45bf4c98..940bd2a9c2 100644
--- a/crates/buzz-agent/src/lib.rs
+++ b/crates/buzz-agent/src/lib.rs
@@ -54,10 +54,10 @@ struct App {
llm: Arc,
sessions: Mutex>,
/// Cached model catalog for Databricks providers. Populated lazily on the
- /// first successful `session/new` discovery call. When discovery fails (e.g.
- /// auth missing or a transient network error) the cell is intentionally left
- /// empty so the next `session/new` call retries — a transient failure never
- /// pins the degraded fallback catalog for the process lifetime.
+ /// first successful `session/new` discovery call. Failed discovery is never
+ /// cached: static-token authentication errors reject session creation, while
+ /// OAuth authentication and non-auth errors use the configured model for that
+ /// response and retry on the next session.
models_cache: tokio::sync::OnceCell>,
}
@@ -135,6 +135,12 @@ pub fn run() -> Result<(), Box> {
Ok(())
}
+pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> {
+ auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config(host))?
+ .interactive_login()
+ .await
+}
+
/// `buzz-agent auth ` — run the interactive auth flow for a
/// provider and persist the result, then exit. Today this supports Databricks
/// OAuth 2.0 PKCE. Reads `DATABRICKS_HOST` from env; needs a browser on the
@@ -145,18 +151,7 @@ async fn auth_subcommand(args: &[String]) -> Result<(), Box {
let host = std::env::var("DATABRICKS_HOST")
.map_err(|_| "auth databricks: DATABRICKS_HOST required")?;
- let pkce = auth::PkceOAuthConfig {
- discovery_url: format!(
- "{}/oidc/.well-known/oauth-authorization-server",
- host.trim_end_matches('/')
- ),
- client_id: "databricks-cli".into(),
- scopes: vec!["all-apis".into(), "offline_access".into()],
- cache_namespace: "databricks".into(),
- cache_dir_override: None,
- };
- let src = auth::PkceOAuthTokenSource::new(pkce)?;
- src.interactive_login().await?;
+ authenticate_databricks(&host).await?;
eprintln!("Authenticated. Token cached under ~/.config/buzz-agent/oauth/databricks/.");
Ok(())
}
@@ -317,26 +312,27 @@ async fn initialize(id: Value, params: Value, wire_tx: &WireSender) {
///
/// Tries to use a previously-cached successful discovery result. If the cache is empty,
/// runs `discover` and — on success — populates the cache for future calls. On failure
-/// the cell is intentionally left empty so the next session retries; the provider-aware
-/// fallback is returned for the immediate response only.
+/// the error is returned and the cell is intentionally left empty so the next session retries.
///
/// Extracted from `session_new` so that tests can drive this path with an injected
/// discovery future without requiring a full `App` / transport stack.
async fn resolve_models_catalog(
cache: &tokio::sync::OnceCell>,
- provider: crate::config::Provider,
- model: &str,
discover: impl std::future::Future, AgentError>>,
-) -> Vec {
- match cache.get_or_try_init(|| discover).await {
- Ok(cached) => cached.clone(),
- Err(e) => {
- tracing::warn!(
- "model catalog discovery failed: {e}; using fallback (will retry next session)"
- );
- crate::catalog::discovery_failure_fallback(provider, model)
- }
- }
+) -> Result, AgentError> {
+ cache.get_or_try_init(|| discover).await.cloned()
+}
+
+/// Return the configured model as a one-entry catalog for this response.
+///
+/// This value is never written to `models_cache`; failed discovery must be retried by
+/// the next session rather than pinning degraded state for the process lifetime.
+fn configured_model_fallback(model: &str) -> Vec {
+ let model = model.trim().to_string();
+ vec![ModelEntry {
+ id: model.clone(),
+ name: model,
+ }]
}
async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) {
@@ -400,6 +396,50 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen
}
Arc::from(prompt)
};
+ // Resolve the model catalog before spawning MCP servers or registering a
+ // session. A configured static credential cannot recover interactively, so
+ // its authentication failure rejects before allocation. OAuth authentication
+ // failures and other catalog failures use only the configured model for this
+ // response, without caching, so session/prompt can run the existing PKCE flow.
+ let available_models: Vec = {
+ use crate::config::Provider;
+ match app.cfg.provider {
+ Provider::Databricks | Provider::DatabricksV2 => {
+ let models = match resolve_models_catalog(
+ &app.models_cache,
+ discover_databricks_models(&app.cfg),
+ )
+ .await
+ {
+ Ok(models) => models,
+ Err(error @ AgentError::LlmAuth(_)) if !app.cfg.api_key.is_empty() => {
+ return reject(wire_tx, id, error.json_rpc_code(), &error.to_string())
+ .await;
+ }
+ Err(error @ AgentError::LlmAuth(_)) => {
+ tracing::warn!(
+ error = %error,
+ "Databricks OAuth model catalog unavailable; using configured model"
+ );
+ configured_model_fallback(&app.cfg.model)
+ }
+ Err(error) => {
+ tracing::warn!(
+ error = %error,
+ "Databricks model catalog unavailable; using configured model"
+ );
+ configured_model_fallback(&app.cfg.model)
+ }
+ };
+ models
+ .iter()
+ .map(|m| json!({ "modelId": m.id, "name": m.name }))
+ .collect()
+ }
+ _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })],
+ }
+ };
+
let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await {
Ok(m) => Arc::new(m),
Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await,
@@ -445,36 +485,6 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen
);
drop(sessions);
- // Build a models catalog for the `session/new` response. For Databricks
- // providers this advertises available models so the desktop ModelPicker and
- // pool can resolve `session/set_model` switches. For Anthropic/OpenAI we
- // report only the configured model — live switching on those providers
- // effectively requires respawn.
- //
- // `models_cache` caches only a successful discovery result (`get_or_try_init`
- // leaves the cell empty on error so the next `session/new` call retries). On
- // discovery failure the fallback is used for the immediate response without
- // being written to the cell.
- let available_models: Vec = {
- use crate::config::Provider;
- match app.cfg.provider {
- Provider::Databricks | Provider::DatabricksV2 => {
- let models = resolve_models_catalog(
- &app.models_cache,
- app.cfg.provider,
- &app.cfg.model,
- discover_databricks_models(&app.cfg),
- )
- .await;
- models
- .iter()
- .map(|m| json!({ "modelId": m.id, "name": m.name }))
- .collect()
- }
- _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })],
- }
- };
-
wire::send(
wire_tx,
wire::ok(
@@ -658,6 +668,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender
effective_model_override,
run_id,
mut steer_rx,
+ usage_baseline,
) = match acquire_session(&app, &p.session_id).await {
Ok(v) => v,
Err(reason) => {
@@ -709,6 +720,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender
turn_output_tokens: &mut turn_output_tokens,
turn_cached_input_tokens: &mut turn_cached_input_tokens,
turn_total_state: &mut turn_total_state,
+ usage_baseline,
};
let result = ctx.run(p.prompt).await;
if let Some(s) = app.sessions.lock().await.get_mut(&sid) {
@@ -766,28 +778,16 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender
if let Some((accumulated_in, accumulated_out, accumulated_cached, accumulated_total)) =
accumulated
{
- // Build the usage_update payload. `accumulatedTotalTokens` is only
- // included when the cumulative is exactly known — never when Unseen
- // (no total ever observed) or Unknown (at least one turn lacked a
- // total). A goose consumer that doesn't recognise the field ignores it.
- let mut update = serde_json::json!({
- "sessionUpdate": "usage_update",
- // used: total tokens as a context-usage proxy;
- // contextLimit: 0 (buzz-agent has no context limit tracking).
- "used": accumulated_in.saturating_add(accumulated_out),
- "contextLimit": 0u64,
- "accumulatedInputTokens": accumulated_in,
- "accumulatedOutputTokens": accumulated_out,
- // A subset of accumulatedInputTokens, not an addition to
- // it. Extends goose's usage_update shape; a consumer that
- // does not know the field ignores it and prices exactly as
- // it did before.
- "accumulatedCachedInputTokens": accumulated_cached,
- "model": effective_model_str,
- });
- if let crate::types::TurnTotalState::Exact(total) = accumulated_total {
- update["accumulatedTotalTokens"] = serde_json::json!(total);
- }
+ // Same builder the run loop uses for its per-round reports, so the
+ // final notification is shape-identical to the ones that preceded
+ // it and a consumer taking the high-water mark lands on this one.
+ let update = wire::usage_update_payload(
+ accumulated_in,
+ accumulated_out,
+ accumulated_cached,
+ accumulated_total,
+ effective_model_str,
+ );
wire::send(&wire_tx, goose_session_update(&sid, update)).await;
}
}
@@ -821,6 +821,7 @@ async fn acquire_session(
Option,
String,
mpsc::UnboundedReceiver>,
+ crate::types::SessionUsageBaseline,
),
&'static str,
> {
@@ -857,6 +858,17 @@ async fn acquire_session(
effective_model,
run_id,
steer_rx,
+ // Snapshot rather than a handle: the run loop reports cumulative usage
+ // after every LLM round, and taking the sessions lock on each of those
+ // would serialise concurrent sessions behind one another's provider
+ // round-trips. Nothing else advances these counters while this turn
+ // holds `busy`, so the snapshot cannot go stale under it.
+ crate::types::SessionUsageBaseline {
+ input_tokens: s.accumulated_input_tokens,
+ output_tokens: s.accumulated_output_tokens,
+ cached_input_tokens: s.accumulated_cached_input_tokens,
+ total_state: s.accumulated_total_state,
+ },
))
}
@@ -868,8 +880,7 @@ fn session_token() -> Result {
#[cfg(test)]
mod tests {
- use crate::catalog::{discovery_failure_fallback, ModelEntry, DATABRICKS_V2_KNOWN_MODELS};
- use crate::config::Provider;
+ use crate::catalog::ModelEntry;
use crate::types::AgentError;
/// Regression: a discovery error must not pin the models_cache for the process lifetime.
@@ -882,23 +893,14 @@ mod tests {
#[tokio::test]
async fn models_cache_does_not_pin_on_discovery_error() {
let cache: tokio::sync::OnceCell> = tokio::sync::OnceCell::new();
- let provider = Provider::DatabricksV2;
- let model = "my-configured-model";
- // First call — discovery fails. Cell must remain empty; fallback returned.
- let first = crate::resolve_models_catalog(&cache, provider, model, async {
- Err::, AgentError>(AgentError::LlmAuth("transient failure".into()))
+ // First call — discovery failure is surfaced and leaves the cell empty.
+ let error = crate::resolve_models_catalog(&cache, async {
+ Err::, AgentError>(AgentError::Llm("transient failure".into()))
})
- .await;
- assert!(
- cache.get().is_none(),
- "cell must be empty after a discovery error — next session must retry"
- );
- let expected_fallback = discovery_failure_fallback(provider, model);
- assert_eq!(
- first, expected_fallback,
- "error path must return the provider-aware fallback"
- );
+ .await
+ .unwrap_err();
+ assert!(matches!(error, AgentError::Llm(_)));
// Second call — discovery succeeds. Cell is now populated and returned.
let discovered = vec![ModelEntry {
@@ -906,10 +908,11 @@ mod tests {
name: "databricks-meta-llama-3-1-70b-instruct".into(),
}];
let discovered_clone = discovered.clone();
- let second = crate::resolve_models_catalog(&cache, provider, model, async move {
+ let second = crate::resolve_models_catalog(&cache, async move {
Ok::, AgentError>(discovered_clone)
})
- .await;
+ .await
+ .unwrap();
assert_eq!(
second, discovered,
"second call must return the discovered catalog"
@@ -925,78 +928,40 @@ mod tests {
);
}
- /// Regression: legacy `Provider::Databricks` must not advertise v2 AI Gateway model IDs
- /// on discovery failure (Wes W1). This test calls `discovery_failure_fallback` directly —
- /// the same helper used by `session_new` — and verifies the split behavior. It FAILS if
- /// the arm is un-split (i.e., if both providers return the v2 catalog on failure).
- #[test]
- fn databricks_discovery_failure_fallback_legacy_returns_configured_model_only() {
- let configured = "my-serving-endpoint";
- let result = discovery_failure_fallback(Provider::Databricks, configured);
-
- // Legacy Databricks must advertise exactly the configured model — nothing more.
- assert_eq!(
- result.len(),
- 1,
- "legacy Databricks fallback must contain exactly one entry, got: {result:?}"
- );
- assert_eq!(
- result[0].id, configured,
- "legacy Databricks fallback must be the configured model"
- );
+ #[tokio::test]
+ async fn models_catalog_does_not_cache_oauth_auth_fallback() {
+ let cache: tokio::sync::OnceCell> = tokio::sync::OnceCell::new();
+ let error = crate::resolve_models_catalog(&cache, async {
+ Err::, AgentError>(AgentError::LlmAuth("sign in again".into()))
+ })
+ .await
+ .unwrap_err();
- // Crucially: must NOT contain any DATABRICKS_V2_KNOWN_MODELS entry.
- let v2_ids: Vec<&str> = DATABRICKS_V2_KNOWN_MODELS.to_vec();
- for id in &result {
- assert!(
- !v2_ids.contains(&id.id.as_str()),
- "legacy Databricks fallback must not include v2 ID '{}' — that endpoint \
- may not be served by /serving-endpoints/{{model}}/invocations",
- id.id
- );
- }
- }
+ assert!(matches!(error, AgentError::LlmAuth(_)));
+ assert!(cache.get().is_none());
- #[test]
- fn databricks_discovery_failure_fallback_v2_returns_known_models_catalog() {
- let configured = "my-configured-model";
- let result = discovery_failure_fallback(Provider::DatabricksV2, configured);
+ let discovered = vec![ModelEntry {
+ id: "authenticated-model".into(),
+ name: "authenticated-model".into(),
+ }];
+ let result = crate::resolve_models_catalog(&cache, async {
+ Ok::, AgentError>(discovered.clone())
+ })
+ .await
+ .unwrap();
- // DatabricksV2 must return the full DATABRICKS_V2_KNOWN_MODELS list,
- // plus the configured model so the picker can still represent the model
- // the agent is actually running.
- assert_eq!(
- result.len(),
- DATABRICKS_V2_KNOWN_MODELS.len() + 1,
- "DatabricksV2 fallback must return all known models plus the configured model"
- );
- let result_ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
- for known_id in DATABRICKS_V2_KNOWN_MODELS {
- assert!(
- result_ids.contains(known_id),
- "DatabricksV2 fallback must include known model '{known_id}'"
- );
- }
- assert!(
- result_ids.contains(&configured),
- "DatabricksV2 fallback must include the configured model"
- );
+ assert_eq!(result, discovered);
+ assert_eq!(cache.get(), Some(&discovered));
}
#[test]
- fn databricks_discovery_failure_fallback_split_verified() {
- // This test FAILS if the v1/v2 arms are merged back into one — it directly verifies
- // that the two providers' error-path behavior diverges (Wes W1 protection).
- let v1 = discovery_failure_fallback(Provider::Databricks, "my-endpoint");
- let v2 = discovery_failure_fallback(Provider::DatabricksV2, "my-endpoint");
-
- let v1_ids: Vec<&str> = v1.iter().map(|m| m.id.as_str()).collect();
- let v2_ids: Vec<&str> = v2.iter().map(|m| m.id.as_str()).collect();
-
- assert_ne!(
- v1_ids, v2_ids,
- "Provider::Databricks and Provider::DatabricksV2 must return different \
- fallback catalogs — if they are equal, the W1 arm split has been reverted"
+ fn configured_model_fallback_is_trimmed_and_singular() {
+ assert_eq!(
+ crate::configured_model_fallback(" configured-model "),
+ vec![ModelEntry {
+ id: "configured-model".into(),
+ name: "configured-model".into(),
+ }]
);
}
}
diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs
index 73c7e1faf2..267b2d21b5 100644
--- a/crates/buzz-agent/src/llm.rs
+++ b/crates/buzz-agent/src/llm.rs
@@ -130,22 +130,13 @@ impl Llm {
) -> Result {
let effort = cfg.thinking_effort;
let result = match cfg.provider {
- Provider::Anthropic => {
- let v = self
- .post_anthropic(
- cfg,
- &anthropic_body(
- cfg,
- system_prompt,
- history,
- tools,
- effective_model,
- effort,
- ),
- )
- .await?;
- parse_anthropic(v)
- }
+ Provider::Anthropic => self
+ .post_anthropic(
+ cfg,
+ &anthropic_body(cfg, system_prompt, history, tools, effective_model, effort),
+ )
+ .await
+ .and_then(parse_anthropic),
Provider::OpenRouter => {
let mut body =
openai_body(cfg, system_prompt, history, tools, effective_model, None);
@@ -155,8 +146,9 @@ impl Llm {
effective_model,
cfg.prompt_caching,
);
- let v = self.post_openrouter(cfg, &body).await?;
- parse_openai_with_reasoning_details(v)
+ self.post_openrouter(cfg, &body)
+ .await
+ .and_then(parse_openai_with_reasoning_details)
}
Provider::OpenAi | Provider::Databricks => {
self.openai_request(
@@ -230,11 +222,21 @@ impl Llm {
// map_err here prepends `(model-name) ` to the inner string only.
// This is the single place all provider paths converge, so the mapping
// is centralized and never needs to be repeated in each provider arm.
+ // Every arm above returns its `Result` into this mapper rather than
+ // using `?` — an early return would silently skip the stamp, which is
+ // exactly what the Anthropic and OpenRouter arms used to do.
result.map_err(|e| match e {
AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")),
AgentError::LlmModelNotFound(s) => {
AgentError::LlmModelNotFound(format!("({effective_model}) {s}"))
}
+ // Stamped like the others: this is the error most likely to be read
+ // during an incident, so it must name the model whose window was
+ // exceeded. Without an explicit arm it would fall through `other`
+ // and be the only unstamped provider error.
+ AgentError::LlmContextExceeded(s) => {
+ AgentError::LlmContextExceeded(format!("({effective_model}) {s}"))
+ }
other => other,
})
}
@@ -1084,6 +1086,29 @@ fn responses_body(
body
}
+/// Narrow matcher for "the input exceeded the model's context window" provider
+/// errors — the ground-truth signal that history must shrink. Only consulted
+/// alongside an HTTP 400 (see the two `!status.is_success()` classification
+/// sites), never on its own: the phrases below are specific, but pairing them
+/// with the status keeps an unrelated 4xx that happens to quote one of them
+/// from triggering a recovery.
+///
+/// Deliberately tight. A generic 400 must stay `AgentError::Llm` so it remains
+/// terminal — misclassifying one as recoverable would spend the whole recovery
+/// budget on an error that shrinking history cannot fix, replacing a clear
+/// failure with a slow one.
+fn is_context_length_error(body: &str) -> bool {
+ let b = body.to_ascii_lowercase();
+ // OpenAI/Databricks machine-readable code; the most reliable marker.
+ b.contains("context_length_exceeded")
+ // Prose forms: OpenAI's classic phrasing and the Databricks gateway's
+ // "context window of this model" variant seen in both bug reports.
+ || b.contains("maximum context length")
+ || b.contains("context window")
+ // Anthropic: "prompt is too long: N tokens > M maximum".
+ || b.contains("prompt is too long")
+}
+
/// Narrow matcher for "you should be on the Responses API" provider errors,
/// the signal we use to auto-upgrade. Triggers on the literal path
/// `/v1/responses` (Databricks GPT-5.5 phrasing) or the prose
@@ -1706,6 +1731,11 @@ fn is_retryable_transport_error(e: &reqwest::Error) -> bool {
e.is_timeout() || e.is_connect() || e.is_request()
}
+fn is_unsupported_image_input_error(body: &str) -> bool {
+ body.to_ascii_lowercase()
+ .contains("no endpoints found that support image input")
+}
+
/// Build the terminal `AgentError::Llm` for a `post()` exit that has given up
/// retrying — persistent retryable status, transport failure, or a body-read
/// break. `detail` carries the specific cause (status/body, or the transport
@@ -1864,15 +1894,30 @@ where
// upstream capacity — no retry was attempted, so cumulative duration
// would be misleading.
if status == 404 {
+ let error_body = read_error_body(resp).await;
+ if is_unsupported_image_input_error(&error_body) {
+ return Err(PostError::Agent(AgentError::UnsupportedImageInput(
+ error_body,
+ )));
+ }
return Err(PostError::Agent(AgentError::LlmModelNotFound(format!(
- "{status}: {}",
- read_error_body(resp).await
+ "{status}: {error_body}"
))));
}
if !status.is_success() {
+ let body = read_error_body(resp).await;
+ // Context-window overflow is a recovery signal, not a terminal
+ // error: classify it here, where status and body are still separate
+ // values. Callers must never re-derive this from the formatted
+ // string — `Llm::complete` stamps the model name onto it before the
+ // agent loop ever sees it.
+ if status == 400 && is_context_length_error(&body) {
+ return Err(PostError::Agent(AgentError::LlmContextExceeded(format!(
+ "{status}: {body}"
+ ))));
+ }
return Err(PostError::Agent(AgentError::Llm(format!(
- "{status}: {}",
- read_error_body(resp).await
+ "{status}: {body}"
))));
}
if let Some(len) = resp.content_length() {
@@ -1910,6 +1955,22 @@ where
unreachable!("loop always returns on its final iteration (attempt + 1 == MAX_RETRIES)");
}
+pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig {
+ PkceOAuthConfig {
+ discovery_url: format!(
+ "{}/oidc/.well-known/oauth-authorization-server",
+ host.trim_end_matches('/')
+ ),
+ client_id: DATABRICKS_CLIENT_ID.into(),
+ scopes: DATABRICKS_OAUTH_SCOPES
+ .iter()
+ .map(|scope| (*scope).into())
+ .collect(),
+ cache_namespace: "databricks".into(),
+ cache_dir_override: None,
+ }
+}
+
/// Build the `TokenSource` for the configured provider.
///
/// - `Provider::Anthropic`: a static source seeded from `cfg.api_key`. It's
@@ -1929,21 +1990,9 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A
if !cfg.api_key.is_empty() {
return Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())));
}
- let discovery_url = format!(
- "{}/oidc/.well-known/oauth-authorization-server",
- cfg.base_url.trim_end_matches('/')
- );
- let pkce = PkceOAuthConfig {
- discovery_url,
- client_id: DATABRICKS_CLIENT_ID.into(),
- scopes: DATABRICKS_OAUTH_SCOPES
- .iter()
- .map(|s| (*s).into())
- .collect(),
- cache_namespace: "databricks".into(),
- cache_dir_override: None,
- };
- Ok(PkceOAuthTokenSource::new(pkce)?)
+ Ok(PkceOAuthTokenSource::new(databricks_pkce_config(
+ &cfg.base_url,
+ ))?)
}
}
}
@@ -2113,6 +2162,9 @@ async fn openrouter_post(
// about the model, and reporting a parameter problem as
// `LlmModelNotFound` (or vice versa) sends the user to the wrong fix.
let error_body = read_error_body(resp).await;
+ if is_unsupported_image_input_error(&error_body) {
+ return Err(AgentError::UnsupportedImageInput(error_body));
+ }
if error_body.contains("No endpoints found that can handle the requested parameters") {
return Err(openrouter_parameter_routing_error(&error_body));
}
@@ -2177,10 +2229,15 @@ async fn openrouter_post(
};
}
if !status.is_success() {
- return Err(AgentError::Llm(format!(
- "{status}: {}",
- read_error_body(resp).await
- )));
+ let body = read_error_body(resp).await;
+ // Same recovery classification as the shared `post()` terminal:
+ // `openrouter_post` is a separate implementation with its own retry
+ // loop and status ladder, so it needs its own arm or OpenRouter
+ // agents keep the permanent context-400 stuck loop.
+ if status == 400 && is_context_length_error(&body) {
+ return Err(AgentError::LlmContextExceeded(format!("{status}: {body}")));
+ }
+ return Err(AgentError::Llm(format!("{status}: {body}")));
}
if let Some(len) = resp.content_length() {
if len as usize > MAX_LLM_RESPONSE_BYTES {
@@ -2483,6 +2540,8 @@ mod tests {
});
let status_text = match response.status {
200 => "OK",
+ 400 => "Bad Request",
+ 413 => "Payload Too Large",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
@@ -6014,6 +6073,8 @@ mod tests {
fn status_line(status: u16) -> &'static str {
match status {
200 => "200 OK",
+ 400 => "400 Bad Request",
+ 413 => "413 Payload Too Large",
401 => "401 Unauthorized",
402 => "402 Payment Required",
403 => "403 Forbidden",
@@ -6127,6 +6188,240 @@ mod tests {
(url, captured, attempts)
}
+ /// Wren's rider: assert on the error emerging from `complete()` for the
+ /// OpenRouter path, not from `openrouter_post`. The bug was the `?` in the
+ /// provider arm, which is invisible from below — a low-level test can see
+ /// the classification but not whether the arm returns it into the
+ /// convergence mapper. The regression test has to cross the layer that had
+ /// the bug.
+ ///
+ /// Two claims here: the variant is `LlmContextExceeded` (so the agent loop
+ /// can recover), and the message carries the `(model)` stamp (so the arm
+ /// reaches the mapper at all). Measured before the fix: variant was correct
+ /// but UNSTAMPED, which is exactly the bypass Wren named.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_context_400_is_typed_and_stamped_through_complete() {
+ let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
+ 400,
+ r#"{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}"#,
+ )])
+ .await;
+ let mut c = cfg(Provider::OpenRouter);
+ c.base_url = url;
+ let llm = Llm::new(&c).unwrap();
+ let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err();
+ assert!(
+ matches!(err, AgentError::LlmContextExceeded(_)),
+ "OpenRouter context-window 400 must classify as LlmContextExceeded, got: {err:?}"
+ );
+ let text = err.to_string();
+ assert!(
+ text.contains("or-model-xyz"),
+ "OpenRouter arm must return into the convergence mapper so the model stamp is \
+ applied; got: {text}"
+ );
+ }
+
+ /// Same two claims on the Anthropic arm — the other `?` Wren named, and the
+ /// other terminal's provider phrasing ("prompt is too long").
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn anthropic_context_400_is_typed_and_stamped_through_complete() {
+ let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse {
+ status: 400,
+ body: json!({"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 300000 tokens > 200000 maximum"}}),
+ }])
+ .await;
+ let mut c = cfg(Provider::Anthropic);
+ c.base_url = base_url;
+ let llm = Llm::new(&c).unwrap();
+ let err = complete_model(&llm, &c, "claude-probe-model")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(err, AgentError::LlmContextExceeded(_)),
+ "Anthropic context-window 400 must classify as LlmContextExceeded, got: {err:?}"
+ );
+ let text = err.to_string();
+ assert!(
+ text.contains("claude-probe-model"),
+ "Anthropic arm must return into the convergence mapper so the model stamp is \
+ applied; got: {text}"
+ );
+ }
+
+ /// Negative arm for the OpenRouter terminal: an ordinary 400 must stay
+ /// `AgentError::Llm`. Paired with the positive above, this is what proves
+ /// the matcher — not the status alone — is doing the classification. The
+ /// body deliberately quotes "tokens" and "model", the words a loose matcher
+ /// would key on.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_ordinary_400_stays_plain_llm_error() {
+ let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
+ 400,
+ r#"{"error":{"message":"Invalid value for 'max_tokens': must be an integer for this model","code":"invalid_value"}}"#,
+ )])
+ .await;
+ let mut c = cfg(Provider::OpenRouter);
+ c.base_url = url;
+ let llm = Llm::new(&c).unwrap();
+ let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err();
+ assert!(
+ matches!(err, AgentError::Llm(_)),
+ "an ordinary 400 must stay a terminal AgentError::Llm, got: {err:?}"
+ );
+ }
+
+ /// Negative arm for the shared `post()` terminal (OpenAI/Databricks), the
+ /// second of the two `!status.is_success()` sites. Same body as the
+ /// OpenRouter negative so the two terminals are compared on equal input.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openai_ordinary_400_stays_plain_llm_error() {
+ let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse {
+ status: 400,
+ body: json!({"error":{"message":"Invalid value for 'max_tokens': must be an integer for this model","code":"invalid_value"}}),
+ }])
+ .await;
+ let mut c = cfg(Provider::OpenAi);
+ c.base_url = base_url;
+ let llm = Llm::new(&c).unwrap();
+ let err = complete_model(&llm, &c, "gpt-probe-model")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(err, AgentError::Llm(_)),
+ "an ordinary 400 must stay a terminal AgentError::Llm, got: {err:?}"
+ );
+ }
+
+ /// Positive arm for the shared `post()` terminal: OpenAI's machine-readable
+ /// `context_length_exceeded` code classifies as recoverable.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openai_context_400_is_typed_through_complete() {
+ let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse {
+ status: 400,
+ body: json!({"error":{"message":"This model's maximum context length is 8192 tokens.","type":"invalid_request_error","code":"context_length_exceeded"}}),
+ }])
+ .await;
+ let mut c = cfg(Provider::OpenAi);
+ c.base_url = base_url;
+ let llm = Llm::new(&c).unwrap();
+ let err = complete_model(&llm, &c, "gpt-probe-model")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(err, AgentError::LlmContextExceeded(_)),
+ "OpenAI context-window 400 must classify as LlmContextExceeded, got: {err:?}"
+ );
+ assert!(
+ err.to_string().contains("gpt-probe-model"),
+ "expected the convergence mapper's model stamp, got: {err}"
+ );
+ }
+
+ /// A context-window 400 must NOT trip the Responses-API auto-upgrade. True
+ /// by construction — `try_upgrade` matches only `AgentError::Llm` and the
+ /// typed variant can never reach it — but asserted because the guarantee
+ /// lives in a pattern match one refactor away from widening, and a silent
+ /// sticky upgrade would reroute every later OpenAI call for the process.
+ ///
+ /// `openai_api = Auto` is load-bearing in BOTH arms: `try_upgrade` is only
+ /// consulted under `Auto` (`llm.rs:587`), so with the test helper's default
+ /// `Chat` the upgrade path is disabled outright and the negative below would
+ /// pass without observing anything. The control caught exactly that.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn context_400_does_not_trip_responses_upgrade() {
+ let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse {
+ status: 400,
+ body: json!({"error":{"message":"This model's maximum context length is 8192 tokens.","code":"context_length_exceeded"}}),
+ }])
+ .await;
+ let mut c = cfg(Provider::OpenAi);
+ c.base_url = base_url;
+ c.openai_api = OpenAiApi::Auto;
+ let llm = Llm::new(&c).unwrap();
+ let err = complete_model(&llm, &c, "gpt-probe-model")
+ .await
+ .unwrap_err();
+ assert!(matches!(err, AgentError::LlmContextExceeded(_)));
+ assert!(
+ !llm.auto_upgraded.load(Ordering::Relaxed),
+ "a context-window 400 must not latch the Responses-API upgrade"
+ );
+ // Positive control: the same helper DOES latch on a genuine
+ // "use the Responses API" error, so the negative above is a real
+ // observation and not a probe that can never fire.
+ let (base_url2, _c2) = spawn_sequence_stub(vec![StubHttpResponse {
+ status: 400,
+ body: json!({"error":{"message":"This model is only supported in /v1/responses"}}),
+ }])
+ .await;
+ let mut c2 = cfg(Provider::OpenAi);
+ c2.base_url = base_url2;
+ c2.openai_api = OpenAiApi::Auto;
+ let llm2 = Llm::new(&c2).unwrap();
+ let _ = complete_model(&llm2, &c2, "gpt-probe-model").await;
+ assert!(
+ llm2.auto_upgraded.load(Ordering::Relaxed),
+ "control: a genuine Responses-API error must latch the upgrade"
+ );
+ }
+
+ /// The `status == 400` conjunct is load-bearing, not belt-and-braces: the
+ /// recovery ladder is only a correct response to an INPUT-SIZE rejection.
+ /// A 403 whose body happens to quote context-window prose (a guardrail
+ /// echoing the request, say) is a permission failure — shrinking history
+ /// cannot fix it, so classifying it as recoverable would burn the whole
+ /// recovery budget on three doomed summarize round-trips and turn a clear
+ /// immediate error into a slow one.
+ ///
+ /// 413 (Payload Too Large) is the right probe status, and picking it took a
+ /// measurement: my first attempt used 403, which BOTH ladders intercept
+ /// earlier (shared `post()` maps 401/403 to `LlmAuth`; `openrouter_post()`
+ /// has its own 403 arm), so those probes never reached the classification
+ /// site at all and the mutant with the conjunct deleted survived them. 413
+ /// is intercepted by neither ladder, so it reaches the same
+ /// `!status.is_success()` terminal the 400 does — and it is the most
+ /// plausible real-world carrier of size prose on a non-400. One arm per
+ /// terminal site.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openai_413_with_context_prose_is_not_recoverable() {
+ let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse {
+ status: 413,
+ body: json!({"error":{"message":"payload too large: this model's maximum context length is 8192 tokens"}}),
+ }])
+ .await;
+ let mut c = cfg(Provider::OpenAi);
+ c.base_url = base_url;
+ let llm = Llm::new(&c).unwrap();
+ let err = complete_model(&llm, &c, "gpt-probe-model")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(err, AgentError::Llm(_)),
+ "only a 400 may classify as a context overflow; a 413 must stay terminal, got: \
+ {err:?}"
+ );
+ }
+
+ /// Same claim at the OpenRouter terminal, which has its own status ladder.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_413_with_context_prose_is_not_recoverable() {
+ let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
+ 413,
+ r#"{"error":{"message":"payload too large: this model's maximum context length is 8192 tokens"}}"#,
+ )])
+ .await;
+ let mut c = cfg(Provider::OpenRouter);
+ c.base_url = url;
+ let llm = Llm::new(&c).unwrap();
+ let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err();
+ assert!(
+ matches!(err, AgentError::Llm(_)),
+ "only a 400 may classify as a context overflow; a 413 must stay terminal, got: \
+ {err:?}"
+ );
+ }
+
/// A 403 (guardrail/moderation/permission rejection, per OpenRouter docs)
/// must NOT be classified as `LlmAuth`: refreshing a static key returns
/// the identical key, so retrying would just waste a duplicate request.
@@ -6213,6 +6508,34 @@ mod tests {
);
}
+ /// A provider's explicit image-capability rejection is a recoverable typed
+ /// error, not a missing model. The agent loop uses this signal to remove the
+ /// image from history before retrying the next LLM round.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_post_404_unsupported_image_is_typed_and_not_retried() {
+ let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
+ 404,
+ r#"{"error":{"message":"No endpoints found that support image input"}}"#,
+ )])
+ .await;
+ let http = Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .unwrap();
+ let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(&err, AgentError::UnsupportedImageInput(s) if s.contains("support image input")),
+ "image rejection must reach the history-recovery path: got {err:?}"
+ );
+ assert_eq!(
+ attempts.load(std::sync::atomic::Ordering::SeqCst),
+ 1,
+ "a deterministic capability rejection must not be retried"
+ );
+ }
+
/// Every other 404 still maps to `LlmModelNotFound`, including one that
/// shares the `No endpoints found` prefix but is about the model rather than
/// the parameters — the discriminator is narrow enough that a genuinely
diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs
index 343a75bf72..4a856f7a87 100644
--- a/crates/buzz-agent/src/types.rs
+++ b/crates/buzz-agent/src/types.rs
@@ -308,6 +308,30 @@ impl TurnTotalState {
}
}
+/// The session-cumulative usage counters as of the START of a turn.
+///
+/// Copied out of the session under the lock when a turn begins and handed to
+/// `RunCtx` by value, so the run loop can emit a cumulative `usage_update`
+/// after every LLM round without reaching back into `App.sessions` (which it
+/// holds no handle to, and which is locked by the turn's own bookkeeping at
+/// both ends).
+///
+/// This exists so that usage is durable *during* a turn rather than only after
+/// it. The counters a turn accrues live in the prompt task's stack frame until
+/// the turn returns; a process killed mid-turn takes them with it and the
+/// tokens are billed by the provider but recorded nowhere. That is not
+/// hypothetical — it silently under-reported a long-horizon benchmark's cost by
+/// several-fold, because every phase of a `continue_until_timeout` run is
+/// terminated mid-turn by design.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct SessionUsageBaseline {
+ pub input_tokens: u64,
+ pub output_tokens: u64,
+ /// The cache-served subset of `input_tokens`, not an addition to it.
+ pub cached_input_tokens: u64,
+ pub total_state: TurnTotalState,
+}
+
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StopReason {
EndTurn,
@@ -364,6 +388,22 @@ pub enum AgentError {
Llm(String),
LlmAuth(String),
LlmModelNotFound(String),
+ /// The provider rejected the request because the input exceeded the
+ /// model's context window (an HTTP 400 whose body names a context-length
+ /// overflow). Typed rather than folded into [`Self::Llm`] because the
+ /// agent loop treats it as a *recovery* signal, not a terminal error: it
+ /// is the only ground-truth indication that history must shrink, needing
+ /// no window estimate that could itself be miscalibrated.
+ ///
+ /// Classified where the HTTP status and body are still separate values, so
+ /// the loop never has to sniff a formatted string — by the time an error
+ /// leaves `Llm::complete` it has already been decorated with the model
+ /// name.
+ LlmContextExceeded(String),
+ /// The provider explicitly rejected image content for the selected model.
+ /// Kept distinct so the agent loop can remove the unsupported image from
+ /// replayed history and give the model a recoverable tool error.
+ UnsupportedImageInput(String),
Mcp(String),
Cancelled,
}
@@ -375,6 +415,8 @@ impl std::fmt::Display for AgentError {
Self::Llm(s) => write!(f, "llm: {s}"),
Self::LlmAuth(s) => write!(f, "llm auth: {s}"),
Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"),
+ Self::LlmContextExceeded(s) => write!(f, "llm context exceeded: {s}"),
+ Self::UnsupportedImageInput(s) => write!(f, "llm image input unsupported: {s}"),
Self::Mcp(s) => write!(f, "mcp: {s}"),
Self::Cancelled => write!(f, "cancelled"),
}
diff --git a/crates/buzz-agent/src/wire.rs b/crates/buzz-agent/src/wire.rs
index 7b50e7982a..634fca03af 100644
--- a/crates/buzz-agent/src/wire.rs
+++ b/crates/buzz-agent/src/wire.rs
@@ -148,6 +148,48 @@ pub fn goose_session_update(sid: &str, update: Value) -> Value {
})
}
+/// Build the `usage_update` payload for a `_goose/unstable/session/update`.
+///
+/// Shared by the two places that report usage — after each LLM round inside a
+/// turn, and once more when the turn completes — so the wire shape cannot drift
+/// between them. A consumer takes the high-water mark per session, so the
+/// mid-turn payloads are supersets of each other and the final one wins; a
+/// divergence in field names or units between the two call sites would instead
+/// show up as tokens silently vanishing, which is the failure this reporting
+/// exists to prevent.
+///
+/// All counts are SESSION-cumulative, matching goose, so buzz-acp's
+/// `UsageTracker` can compute per-turn deltas symmetrically for both agents.
+pub fn usage_update_payload(
+ accumulated_input_tokens: u64,
+ accumulated_output_tokens: u64,
+ accumulated_cached_input_tokens: u64,
+ accumulated_total: crate::types::TurnTotalState,
+ model: &str,
+) -> Value {
+ let mut update = json!({
+ "sessionUpdate": "usage_update",
+ // used: total tokens as a context-usage proxy;
+ // contextLimit: 0 (buzz-agent has no context limit tracking).
+ "used": accumulated_input_tokens.saturating_add(accumulated_output_tokens),
+ "contextLimit": 0u64,
+ "accumulatedInputTokens": accumulated_input_tokens,
+ "accumulatedOutputTokens": accumulated_output_tokens,
+ // A subset of accumulatedInputTokens, not an addition to it. Extends
+ // goose's usage_update shape; a consumer that does not know the field
+ // ignores it and prices exactly as it did before.
+ "accumulatedCachedInputTokens": accumulated_cached_input_tokens,
+ "model": model,
+ });
+ // Only when the cumulative is exactly known — never when Unseen (no total
+ // ever observed) or Unknown (at least one turn lacked a total). A goose
+ // consumer that doesn't recognise the field ignores it.
+ if let Some(total) = accumulated_total.exact_value() {
+ update["accumulatedTotalTokens"] = json!(total);
+ }
+ update
+}
+
/// A `session/update` notification carrying a `update._meta.goose.` field.
/// Used to advertise `activeRunId` (so steer-capable clients can target the
/// in-flight run) and `queuedSteer` (so they can correlate an accepted steer
diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs
index 5b660da48c..1b7f346162 100644
--- a/crates/buzz-agent/tests/bin/fake_mcp.rs
+++ b/crates/buzz-agent/tests/bin/fake_mcp.rs
@@ -12,6 +12,7 @@
//! (use a large value, e.g. 999, to simulate hang)
//! FAKE_MCP_RESULT_SIZE=N — `tools/call` returns an N-byte text result
//! (default: the literal "ok"); grows history
+//! FAKE_MCP_IMAGE_RESULT=1 — `tools/call` returns text plus a PNG image block
//! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup
//! (for tests that want to verify the child died)
//! FAKE_MCP_SPAWN_GRANDCHILD=1
@@ -300,10 +301,18 @@ fn main() {
} else {
"ok".to_owned()
};
+ let content = if env_flag("FAKE_MCP_IMAGE_RESULT") {
+ json!([
+ { "type": "text", "text": result_text },
+ { "type": "image", "data": "aW1n", "mimeType": "image/png" },
+ ])
+ } else {
+ json!([{ "type": "text", "text": result_text }])
+ };
write_response(
id,
json!({
- "content": [{ "type": "text", "text": result_text }],
+ "content": content,
"isError": false,
}),
);
diff --git a/crates/buzz-agent/tests/databricks_oauth.rs b/crates/buzz-agent/tests/databricks_oauth.rs
index 52acee1076..fbe0dc1f86 100644
--- a/crates/buzz-agent/tests/databricks_oauth.rs
+++ b/crates/buzz-agent/tests/databricks_oauth.rs
@@ -20,6 +20,7 @@ use axum::{routing::get, routing::post, Json, Router};
use buzz_agent::auth::{PkceOAuthConfig, PkceOAuthTokenSource, TokenSource};
use serde::Deserialize;
use serde_json::json;
+use sha2::{Digest, Sha256};
use tempfile::TempDir;
#[derive(Deserialize)]
@@ -457,6 +458,7 @@ struct AgentHarness {
stdin: tokio::process::ChildStdin,
stdout: BufReader,
next_id: i64,
+ _home: Option,
}
impl Drop for AgentHarness {
@@ -467,20 +469,65 @@ impl Drop for AgentHarness {
impl AgentHarness {
async fn spawn_provider(provider: &str, base_url: &str, model: &str) -> Self {
+ Self::spawn_provider_with_options(provider, base_url, model, 1, Some("test-bearer")).await
+ }
+
+ async fn spawn_oauth_provider(
+ provider: &str,
+ base_url: &str,
+ model: &str,
+ max_sessions: usize,
+ ) -> Self {
+ Self::spawn_provider_with_options(provider, base_url, model, max_sessions, None).await
+ }
+
+ async fn spawn_provider_with_max_sessions(
+ provider: &str,
+ base_url: &str,
+ model: &str,
+ max_sessions: usize,
+ ) -> Self {
+ Self::spawn_provider_with_options(
+ provider,
+ base_url,
+ model,
+ max_sessions,
+ Some("test-bearer"),
+ )
+ .await
+ }
+
+ async fn spawn_provider_with_options(
+ provider: &str,
+ base_url: &str,
+ model: &str,
+ max_sessions: usize,
+ token: Option<&str>,
+ ) -> Self {
let bin = env!("CARGO_BIN_EXE_buzz-agent");
+ let home = token
+ .is_none()
+ .then(|| TempDir::new().expect("create isolated OAuth home"));
let mut cmd = tokio::process::Command::new(bin);
cmd.env("BUZZ_AGENT_PROVIDER", provider)
.env("DATABRICKS_HOST", base_url)
.env("DATABRICKS_MODEL", model)
- .env("DATABRICKS_TOKEN", "test-bearer")
+ .env_remove("DATABRICKS_TOKEN")
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
.env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5")
.env("BUZZ_AGENT_MAX_ROUNDS", "2")
+ .env("BUZZ_AGENT_MAX_SESSIONS", max_sessions.to_string())
.env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
+ if let Some(token) = token {
+ cmd.env("DATABRICKS_TOKEN", token);
+ }
+ if let Some(home) = &home {
+ cmd.env("HOME", home.path());
+ }
let mut child = cmd.spawn().expect("spawn buzz-agent");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
@@ -489,9 +536,17 @@ impl AgentHarness {
stdin,
stdout,
next_id: 1,
+ _home: home,
}
}
+ fn oauth_home(&self) -> &std::path::Path {
+ self._home
+ .as_ref()
+ .expect("harness was not started in OAuth mode")
+ .path()
+ }
+
async fn send(&mut self, method: &str, params: serde_json::Value) -> i64 {
let id = self.next_id;
self.next_id += 1;
@@ -938,3 +993,288 @@ async fn session_set_model_empty_model_id_returns_error() {
"error message must mention modelId, got: {msg}"
);
}
+
+#[tokio::test]
+async fn model_discovery_surfaces_rejected_static_token_as_auth_failure() {
+ use axum::http::StatusCode;
+ use buzz_agent::config::{Config, Provider};
+ use buzz_agent::discover_databricks_models;
+
+ let requests = Arc::new(AtomicU64::new(0));
+ let requests_for_route = requests.clone();
+ let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
+ .await
+ .unwrap();
+ let host = format!("http://{}", listener.local_addr().unwrap());
+ let app = Router::new().route(
+ "/api/ai-gateway/v2/endpoints",
+ get(move || {
+ let requests = requests_for_route.clone();
+ async move {
+ requests.fetch_add(1, Ordering::SeqCst);
+ (StatusCode::UNAUTHORIZED, "rejected bearer rejected")
+ }
+ }),
+ );
+ tokio::spawn(async move {
+ let _ = axum::serve(listener, app).await;
+ });
+
+ let cfg = Config::for_discovery(Provider::DatabricksV2, "rejected".into(), host);
+ let error = discover_databricks_models(&cfg).await.unwrap_err();
+
+ assert!(
+ error.to_string().starts_with("llm auth:"),
+ "401 must retain auth semantics: {error}"
+ );
+ assert!(
+ !error.to_string().contains("rejected bearer"),
+ "auth errors must not propagate provider bodies that may echo credentials: {error}"
+ );
+ assert_eq!(
+ requests.load(Ordering::SeqCst),
+ 1,
+ "a static token cannot refresh, so discovery must not issue a duplicate request"
+ );
+}
+
+fn databricks_oauth_cache_path(home: &std::path::Path, host: &str) -> std::path::PathBuf {
+ let discovery_url = format!(
+ "{}/oidc/.well-known/oauth-authorization-server",
+ host.trim_end_matches('/')
+ );
+ let mut hasher = Sha256::new();
+ hasher.update(discovery_url.as_bytes());
+ hasher.update(b"|");
+ hasher.update(b"databricks-cli");
+ hasher.update(b"|");
+ hasher.update(b"all-apis,offline_access");
+ let hash = hex::encode(hasher.finalize());
+ home.join(".config")
+ .join("buzz-agent")
+ .join("oauth")
+ .join("databricks")
+ .join(format!("{hash}.json"))
+}
+
+fn write_cached_oauth_token(home: &std::path::Path, host: &str, access_token: &str) {
+ let path = databricks_oauth_cache_path(home, host);
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(
+ path,
+ serde_json::to_vec(&json!({
+ "access_token": access_token,
+ "refresh_token": null,
+ "expires_at": SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+ + 3600,
+ }))
+ .unwrap(),
+ )
+ .unwrap();
+}
+
+#[tokio::test]
+async fn oauth_missing_token_uses_configured_model_then_retries_discovery() {
+ let attempts = Arc::new(AtomicU64::new(0));
+ let attempts_for_route = attempts.clone();
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let host = format!("http://{}", listener.local_addr().unwrap());
+ let app = Router::new().route(
+ "/api/ai-gateway/v2/endpoints",
+ get(move || {
+ let attempts = attempts_for_route.clone();
+ async move {
+ attempts.fetch_add(1, Ordering::SeqCst);
+ Json(json!({
+ "endpoints": [{"name": "authenticated-model"}],
+ "next_page_token": null,
+ }))
+ }
+ }),
+ );
+ tokio::spawn(async move {
+ let _ = axum::serve(listener, app).await;
+ });
+
+ let configured_model = " configured-model ";
+ let mut h =
+ AgentHarness::spawn_oauth_provider("databricks_v2", &host, configured_model, 2).await;
+ let initialize = h
+ .send(
+ "initialize",
+ json!({ "protocolVersion": 1, "clientCapabilities": {} }),
+ )
+ .await;
+ assert!(h.recv_for(initialize).await.get("result").is_some());
+
+ let first = h
+ .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] }))
+ .await;
+ let first_response = h.recv_for(first).await;
+ assert!(
+ first_response["result"]["sessionId"].is_string(),
+ "missing OAuth token blocked session creation: {first_response}"
+ );
+ assert_eq!(
+ first_response["result"]["models"]["availableModels"],
+ json!([{"modelId": "configured-model", "name": "configured-model"}])
+ );
+ assert_eq!(attempts.load(Ordering::SeqCst), 0);
+
+ write_cached_oauth_token(h.oauth_home(), &host, "cached-bearer");
+
+ let second = h
+ .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] }))
+ .await;
+ let second_response = h.recv_for(second).await;
+ assert!(
+ second_response["result"]["sessionId"].is_string(),
+ "later authenticated session failed: {second_response}"
+ );
+ assert_eq!(
+ second_response["result"]["models"]["availableModels"],
+ json!([{"modelId": "authenticated-model", "name": "authenticated-model"}])
+ );
+ assert_eq!(
+ attempts.load(Ordering::SeqCst),
+ 1,
+ "OAuth fallback was cached instead of retrying discovery"
+ );
+}
+
+#[tokio::test]
+async fn non_auth_discovery_failure_uses_configured_model_without_caching_fallback() {
+ use axum::http::StatusCode;
+
+ let attempts = Arc::new(AtomicU64::new(0));
+ let attempts_for_route = attempts.clone();
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let host = format!("http://{}", listener.local_addr().unwrap());
+ let app = Router::new().route(
+ "/api/ai-gateway/v2/endpoints",
+ get(move || {
+ let attempts = attempts_for_route.clone();
+ async move {
+ attempts.fetch_add(1, Ordering::SeqCst);
+ (StatusCode::SERVICE_UNAVAILABLE, "catalog unavailable")
+ }
+ }),
+ );
+ tokio::spawn(async move {
+ let _ = axum::serve(listener, app).await;
+ });
+
+ let configured_model = " configured-model ";
+ let normalized_configured_model = configured_model.trim();
+ let mut h =
+ AgentHarness::spawn_provider_with_max_sessions("databricks_v2", &host, configured_model, 2)
+ .await;
+ let initialize = h
+ .send(
+ "initialize",
+ json!({ "protocolVersion": 1, "clientCapabilities": {} }),
+ )
+ .await;
+ assert!(h.recv_for(initialize).await.get("result").is_some());
+
+ for expected_attempts in 1..=2 {
+ let request = h
+ .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] }))
+ .await;
+ let response = h.recv_for(request).await;
+ assert!(
+ response["result"]["sessionId"].is_string(),
+ "non-auth catalog failure blocked session creation: {response}"
+ );
+ assert_eq!(
+ response["result"]["models"]["availableModels"],
+ json!([{"modelId": normalized_configured_model, "name": normalized_configured_model}])
+ );
+ assert_eq!(attempts.load(Ordering::SeqCst), expected_attempts);
+ }
+}
+
+#[tokio::test]
+async fn rejected_static_token_does_not_consume_capacity_or_spawn_mcp() {
+ use axum::http::StatusCode;
+
+ let attempts = Arc::new(AtomicU64::new(0));
+ let attempts_for_route = attempts.clone();
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let host = format!("http://{}", listener.local_addr().unwrap());
+ let app = Router::new().route(
+ "/api/ai-gateway/v2/endpoints",
+ get(move || {
+ let attempts = attempts_for_route.clone();
+ async move {
+ if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
+ Err((StatusCode::UNAUTHORIZED, "rejected"))
+ } else {
+ Ok(Json(json!({
+ "endpoints": [{"name": "discovered-model"}],
+ "next_page_token": null,
+ })))
+ }
+ }
+ }),
+ );
+ tokio::spawn(async move {
+ let _ = axum::serve(listener, app).await;
+ });
+
+ let mut h = AgentHarness::spawn_provider("databricks_v2", &host, "discovered-model").await;
+ let initialize = h
+ .send(
+ "initialize",
+ json!({ "protocolVersion": 1, "clientCapabilities": {} }),
+ )
+ .await;
+ assert!(h.recv_for(initialize).await.get("result").is_some());
+
+ let pid_dir = TempDir::new().unwrap();
+ let pid_file = pid_dir.path().join("mcp.pid");
+ let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
+ let mcp_servers = json!([{
+ "name": "must-not-spawn",
+ "command": fake_mcp,
+ "args": [],
+ "env": [{
+ "name": "FAKE_MCP_PID_FILE",
+ "value": pid_file.to_string_lossy(),
+ }],
+ }]);
+
+ let failed = h
+ .send(
+ "session/new",
+ json!({ "cwd": "/tmp", "mcpServers": mcp_servers }),
+ )
+ .await;
+ let failed_response = h.recv_for(failed).await;
+ assert!(failed_response.get("error").is_some(), "{failed_response}");
+ assert!(
+ failed_response["error"]["message"]
+ .as_str()
+ .unwrap_or_default()
+ .contains("llm auth"),
+ "rejected static token did not retain auth semantics: {failed_response}"
+ );
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ assert!(
+ !pid_file.exists(),
+ "MCP process spawned before failed discovery was resolved"
+ );
+
+ let retry = h
+ .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] }))
+ .await;
+ let retry_response = h.recv_for(retry).await;
+ assert!(
+ retry_response["result"]["sessionId"].is_string(),
+ "failed discovery consumed the sole session slot: {retry_response}"
+ );
+ assert_eq!(attempts.load(Ordering::SeqCst), 2);
+}
diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs
index f782a9d476..4253ef329c 100644
--- a/crates/buzz-agent/tests/fake_llm.rs
+++ b/crates/buzz-agent/tests/fake_llm.rs
@@ -57,9 +57,26 @@ async fn spawn_fake_llm(responses: Vec) -> String {
url
}
+struct CannedResponse {
+ status: u16,
+ body: Value,
+}
+
/// Like `spawn_fake_llm` but also captures the full JSON request body from each
/// incoming HTTP request. Returns (url, captured_requests).
async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc>>) {
+ spawn_capturing_fake_llm_with_statuses(
+ responses
+ .into_iter()
+ .map(|body| CannedResponse { status: 200, body })
+ .collect(),
+ )
+ .await
+}
+
+async fn spawn_capturing_fake_llm_with_statuses(
+ responses: Vec,
+) -> (String, Arc>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
@@ -122,15 +139,22 @@ async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc = frames_before
+ .iter()
+ .filter(|v| is_usage_update(v))
+ .collect();
+ assert!(
+ usage.len() >= 2,
+ "expected a usage_update per round (2 rounds), got {}; frames: {frames_before:#?}",
+ usage.len()
+ );
+
+ // Round 1 alone — emitted while round 2 was still outstanding.
+ assert_eq!(
+ usage[0]["params"]["update"]["accumulatedInputTokens"],
+ json!(15u64),
+ "first notification must carry round 1's input tokens only"
+ );
+ assert_eq!(
+ usage[0]["params"]["update"]["accumulatedOutputTokens"],
+ json!(6u64),
+ "first notification must carry round 1's output tokens only"
+ );
+
+ // The last one is the turn total and is what a high-water-mark consumer keeps.
+ let last = usage[usage.len() - 1];
+ assert_eq!(
+ last["params"]["update"]["accumulatedInputTokens"],
+ json!(35u64),
+ "final notification must carry the turn total 15+20=35"
+ );
+ assert_eq!(
+ last["params"]["update"]["accumulatedOutputTokens"],
+ json!(14u64),
+ "final notification must carry the turn total 6+8=14"
+ );
+
+ h.shutdown().await;
+}
+
+/// A mid-turn report must be SESSION-cumulative, not turn-local.
+///
+/// The baseline handed to the run loop is a snapshot taken when the turn began;
+/// if it were dropped, a consumer taking the high-water mark per session would
+/// see turn 2's first round (a small number) arrive after turn 1's total and
+/// discard it, silently losing turn 2 for any turn that never completed.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn mid_turn_usage_includes_earlier_turns() {
+ let url = spawn_fake_llm(vec![
+ openai_text_with_usage("turn one", 10, 5),
+ openai_tool_call_with_usage("call_t2", "fake__noop", json!({}), 20, 8),
+ openai_text_with_usage("turn two done", 30, 9),
+ ])
+ .await;
+ let mut h = Harness::spawn(&url).await;
+ let sid = init_session(&mut h).await;
+
+ let p1 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 1"}]}),
+ )
+ .await;
+ let (_, _) = recv_until_with_drain(&mut h, |v| v["id"] == p1).await;
+
+ let p2 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 2"}]}),
+ )
+ .await;
+ let (frames_before, _) = recv_until_with_drain(&mut h, |v| v["id"] == p2).await;
+
+ let first = frames_before
+ .iter()
+ .find(|v| is_usage_update(v))
+ .unwrap_or_else(|| {
+ panic!("expected a usage_update during turn 2; frames: {frames_before:#?}")
+ });
+ assert_eq!(
+ first["params"]["update"]["accumulatedInputTokens"],
+ json!(30u64),
+ "turn 2 round 1 must report 10 (turn 1) + 20 (this round), not 20"
+ );
+ assert_eq!(
+ first["params"]["update"]["accumulatedOutputTokens"],
+ json!(13u64),
+ "turn 2 round 1 must report 5 (turn 1) + 8 (this round), not 8"
+ );
+
+ h.shutdown().await;
+}
+
/// When a turn is cancelled AFTER the provider has already returned a response
/// (so token counts are observed), buzz-agent must still emit the usage
/// notification before the cancelled `session/prompt` response.
diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs
index abb4f7b311..c82be76dc0 100644
--- a/crates/buzz-agent/tests/regressions.rs
+++ b/crates/buzz-agent/tests/regressions.rs
@@ -21,6 +21,13 @@ struct CapturingLlm {
}
async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm {
+ spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await
+}
+
+/// Like `spawn_capturing_llm` but each canned response carries its own HTTP
+/// status, so a test can serve a real provider rejection (e.g. a context-window
+/// 400) instead of only success bodies.
+async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
@@ -66,14 +73,19 @@ async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm {
if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) {
captured.lock().await.push(req);
}
- let body = queue
+ let (status, body) = queue
.lock()
.await
.pop_front()
- .unwrap_or_else(|| json!({ "error": "no canned response" }));
+ .unwrap_or_else(|| (200, json!({ "error": "no canned response" })));
let body_s = serde_json::to_string(&body).unwrap();
+ let reason = match status {
+ 200 => "OK",
+ 400 => "Bad Request",
+ _ => "Error",
+ };
let resp = format!(
- "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+ "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body_s.len(), body_s,
);
let _ = sock.write_all(resp.as_bytes()).await;
@@ -2281,3 +2293,1293 @@ fn reply_guard_rejects_unparseable_toggle() {
"expected the offending key in the error, got: {stderr}"
);
}
+
+/// A prompt large enough that the recovery ladder's halving stays above
+/// `HANDOFF_MIN_PROMPT_BUDGET_BYTES` (4 KiB) for all three rungs.
+///
+/// This is load-bearing, not decoration: with a tiny history the ladder
+/// correctly refuses on the FIRST rung (halving a 49-byte history lands at 24
+/// bytes, far under the floor), so a small fixture cannot exercise recovery at
+/// all — it exercises the floor. `marker` is embedded so the prompt is still
+/// identifiable in a captured request body.
+fn large_prompt(marker: &str) -> String {
+ let mut s = String::with_capacity(64 * 1024 + marker.len());
+ s.push_str(marker);
+ s.push(' ');
+ while s.len() < 64 * 1024 {
+ s.push_str("filler context to make the history realistically large. ");
+ }
+ s
+}
+
+/// OpenAI-compatible context-window rejection body, matching the shape the
+/// provider actually returns on overflow.
+fn openai_context_length_error() -> Value {
+ json!({
+ "error": {
+ "message": "This model's maximum context length is 8192 tokens. \
+ However, your messages resulted in 20000 tokens.",
+ "type": "invalid_request_error",
+ "code": "context_length_exceeded",
+ }
+ })
+}
+
+/// A 400 that is NOT a context-window overflow — the negative control for the
+/// matcher. Deliberately quotes "tokens" and "model", the words a sloppy
+/// matcher would key on.
+fn openai_ordinary_400() -> Value {
+ json!({
+ "error": {
+ "message": "Invalid value for 'max_tokens': must be an integer for this model",
+ "type": "invalid_request_error",
+ "code": "invalid_value",
+ }
+ })
+}
+
+/// THE BUG. A provider context-window 400 must be recovered from in-loop, not
+/// propagated out of `run()`.
+///
+/// Without the reactive path this is a permanent stick, and the mechanism is
+/// what makes it permanent rather than transient: a failed request reports no
+/// usage, so `last_request_input_tokens` stays frozen at the last SUCCESSFUL
+/// (sub-threshold) reading, `should_handoff()` therefore returns false forever,
+/// and the in-memory session keeps the same oversized history. Every later
+/// prompt in that session fails identically, for the life of the session.
+/// (Restarting the agent clears it — history is not written to disk — which is
+/// why the only workaround today is a restart.)
+///
+/// The sequence here reproduces exactly that state: request 1 succeeds and
+/// reports usage well UNDER the threshold (so the proactive gate is provably
+/// not what fires), request 2 is rejected with a context-window 400. The agent
+/// must force a handoff and retry, so the prompt still ends in a normal
+/// `end_turn` rather than a JSON-RPC error.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn context_window_400_recovers_instead_of_sticking() {
+ let llm = spawn_capturing_llm_with_status(vec![
+ // req 1: succeeds, usage 10 tokens — far under any threshold.
+ (200, openai_text_with_usage("ack", 10)),
+ // req 2: the overflow rejection.
+ (400, openai_context_length_error()),
+ // req 3: the forced handoff's summarize() call.
+ (200, openai_text("recovered handoff summary")),
+ // req 4: the retried completion, now on fresh history.
+ (200, openai_text_with_usage("done after recovery", 10)),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ // Large window + large byte budget: neither proactive gate can be
+ // what produces the handoff, so a handoff here is attributable to
+ // the reactive path alone.
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
+ ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ // Cap of 0: proves the forced path bypasses `max_handoffs`. Any
+ // gated handoff is impossible under this setting.
+ ("BUZZ_AGENT_MAX_HANDOFFS", "0"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let p0 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"first prompt, succeeds"}]}),
+ )
+ .await;
+ let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
+ assert!(
+ r0["result"].get("stopReason").is_some(),
+ "first prompt should succeed: {r0}"
+ );
+
+ // Second prompt: its first completion is rejected for context overflow.
+ let p1 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("second-prompt-overflows")}]}),
+ )
+ .await;
+ let r1 = h.recv_until(|v| v["id"] == json!(p1)).await;
+ assert!(
+ r1.get("error").is_none(),
+ "context-window 400 must be recovered in-loop, not returned as an error: {r1} \
+ stderr={}",
+ h.stderr_text()
+ );
+ assert_eq!(
+ r1["result"]["stopReason"],
+ "end_turn",
+ "expected the turn to finish after recovery: {r1} stderr={}",
+ h.stderr_text()
+ );
+ // 4 requests = the rejected one, the summarize, and the retry. 2 would mean
+ // no recovery was attempted.
+ let captured = llm.captured.lock().await.len();
+ assert_eq!(
+ captured,
+ 4,
+ "expected reject + summarize + retry (4 reqs total), saw {captured} — stderr={}",
+ h.stderr_text()
+ );
+ let stderr = h.stderr_text();
+ assert!(
+ stderr.contains("provider reported context overflow; forcing handoff"),
+ "expected the forced-handoff log line, got: {stderr}"
+ );
+ h.shutdown().await;
+}
+
+/// A successful recovery must actually send the recovered completion, even when
+/// `max_rounds` is finite. `round` is incremented BEFORE the completion that
+/// gets rejected, so a naive `continue` after recovery re-enters the loop with
+/// the rejected attempt already charged against the cap: with
+/// `BUZZ_AGENT_MAX_ROUNDS=1` the turn would return `max_turn_requests` after
+/// destructively resetting history, having never sent the retry. That silently
+/// converts "recovered" into "history destroyed, question unanswered" — worse
+/// than the error it replaced, because the user gets a stop reason rather than a
+/// failure.
+///
+/// The default `max_rounds` is 0 (unbounded), which is why the rest of the
+/// matrix cannot see this: the cap check at the top of the loop never fires.
+///
+/// `max_rounds=1` is also the tightest possible setting, so it pins the
+/// boundary: exactly one round is authorized, the rejected request must not
+/// consume it, and the retry must be the request that spends it.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn recovery_retry_is_sent_under_a_finite_round_cap() {
+ let llm = spawn_capturing_llm_with_status(vec![
+ // req 1: the overflow rejection (round 1 charged before it is sent).
+ (400, openai_context_length_error()),
+ // req 2: the forced handoff's summarize() call.
+ (200, openai_text("recovered handoff summary")),
+ // req 3: the retried completion. Under the bug this is never sent.
+ (200, openai_text_with_usage("done after recovery", 10)),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
+ ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ ("BUZZ_AGENT_MAX_HANDOFFS", "0"),
+ // The whole point: a finite cap, at its tightest.
+ ("BUZZ_AGENT_MAX_ROUNDS", "1"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let p0 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("overflows-under-finite-cap")}]}),
+ )
+ .await;
+ let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
+ assert!(
+ r0.get("error").is_none(),
+ "context-window 400 must be recovered in-loop: {r0} stderr={}",
+ h.stderr_text()
+ );
+ // The discriminator. `max_turn_requests` here means recovery ran, history
+ // was reset, and the turn ended without ever asking the model again.
+ assert_eq!(
+ r0["result"]["stopReason"],
+ "end_turn",
+ "a recovered turn must finish by answering, not by hitting the round cap: {r0} \
+ stderr={}",
+ h.stderr_text()
+ );
+ // 3 requests = reject + summarize + retry. 2 would mean the retry was
+ // never sent (the bug); the outcome assertion alone cannot tell those apart
+ // if the stop reason were ever produced some other way.
+ let captured = llm.captured.lock().await.len();
+ assert_eq!(
+ captured,
+ 3,
+ "expected reject + summarize + retry (3 reqs), saw {captured} — stderr={}",
+ h.stderr_text()
+ );
+ h.shutdown().await;
+}
+
+/// The finite round cap must still bind for ORDINARY rounds — the recovery
+/// refund must not become a general amnesty. With `max_rounds=1` and no context
+/// overflow anywhere, a model that keeps requesting tool calls gets exactly one
+/// completion and then `max_turn_requests`.
+///
+/// Without this arm, "make the recovered retry possible" is satisfiable by
+/// deleting the cap, and the test above would still pass.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn finite_round_cap_still_binds_without_a_context_overflow() {
+ let llm = spawn_capturing_llm_with_status(vec![
+ // Round 1: a tool call, which would normally drive another round.
+ (
+ 200,
+ openai_tool_call("tc1", "dev__shell", json!({"command": "true"})),
+ ),
+ // Never reached: the cap must stop the turn before a second completion.
+ (200, openai_text_with_usage("should not be sent", 10)),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
+ ("BUZZ_AGENT_MAX_ROUNDS", "1"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let p0 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"drive a tool call"}]}),
+ )
+ .await;
+ let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
+ assert_eq!(
+ r0["result"]["stopReason"],
+ "max_turn_requests",
+ "an ordinary finite cap must still bind: {r0} stderr={}",
+ h.stderr_text()
+ );
+ let captured = llm.captured.lock().await.len();
+ assert_eq!(
+ captured,
+ 1,
+ "exactly one completion is authorized by max_rounds=1, saw {captured} — stderr={}",
+ h.stderr_text()
+ );
+ h.shutdown().await;
+}
+
+/// Prompt-exactly-once across a forced handoff: the live user prompt must be
+/// retained in the fresh history exactly once — not dropped (the model would
+/// answer a question it can no longer see) and not duplicated (a doubled prompt
+/// re-inflates the context we just shrank, and can produce a doubled action).
+///
+/// Asserted on the retry request's own message array, which is the only place
+/// the post-reset history is observable from outside.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn forced_handoff_retains_live_prompt_exactly_once() {
+ const MARKER: &str = "unique-live-prompt-marker-7f3a";
+ let llm = spawn_capturing_llm_with_status(vec![
+ (200, openai_text_with_usage("ack", 10)),
+ (400, openai_context_length_error()),
+ (200, openai_text("summary body")),
+ (200, openai_text_with_usage("done", 10)),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ ("BUZZ_AGENT_MAX_HANDOFFS", "0"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+ let p0 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"warmup"}]}),
+ )
+ .await;
+ let _ = h.recv_until(|v| v["id"] == json!(p0)).await;
+ let p1 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt(MARKER)}]}),
+ )
+ .await;
+ let r1 = h.recv_until(|v| v["id"] == json!(p1)).await;
+ assert!(r1.get("error").is_none(), "expected recovery: {r1}");
+
+ let captured = llm.captured.lock().await;
+ let retry = captured
+ .last()
+ .expect("at least one captured request")
+ .clone();
+ drop(captured);
+ let messages = retry["messages"]
+ .as_array()
+ .unwrap_or_else(|| panic!("retry request had no messages array: {retry}"));
+ let occurrences = messages
+ .iter()
+ .filter(|m| {
+ m["content"]
+ .as_str()
+ .map(|s| s.contains(MARKER))
+ .unwrap_or(false)
+ })
+ .count();
+ assert_eq!(
+ occurrences, 1,
+ "live prompt must appear exactly once in post-handoff history, saw {occurrences} in \
+ {messages:#?}"
+ );
+ h.shutdown().await;
+}
+
+/// Negative control at the loop layer: an ordinary 400 must stay terminal.
+///
+/// This is the arm that keeps the recovery narrow. If the matcher were loose,
+/// this request would be classified as recoverable, the agent would spend its
+/// whole recovery budget summarizing, and a clear immediate failure would
+/// become a slow one — with three wasted provider round-trips. Exactly one
+/// request, and the prompt returns an error.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn ordinary_400_stays_terminal_and_triggers_no_recovery() {
+ let llm = spawn_capturing_llm_with_status(vec![(400, openai_ordinary_400())]).await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
+ ("BUZZ_AGENT_MAX_HANDOFFS", "3"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+ let p0 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"hello"}]}),
+ )
+ .await;
+ let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
+ assert!(
+ r0.get("error").is_some(),
+ "an ordinary 400 must surface as an error, got: {r0}"
+ );
+ let captured = llm.captured.lock().await.len();
+ assert_eq!(
+ captured,
+ 1,
+ "an ordinary 400 must not trigger a recovery attempt; saw {captured} requests — \
+ stderr={}",
+ h.stderr_text()
+ );
+ let stderr = h.stderr_text();
+ assert!(
+ !stderr.contains("provider reported context overflow"),
+ "ordinary 400 must not be classified as a context overflow, got: {stderr}"
+ );
+ h.shutdown().await;
+}
+
+/// The recovery budget must be finite: a provider that rejects every request
+/// for context overflow — including the retries — has to surface the error
+/// rather than being rescued forever. `max_rounds` cannot bound this (it
+/// defaults to 0/unbounded), so the per-`run()` recovery budget is the only
+/// thing standing between this case and an infinite loop.
+///
+/// The stub returns a context-400 to EVERY request, so a missing bound shows up
+/// as a hang rather than a wrong answer — hence the explicit timeout, which is
+/// part of the assertion.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn context_recovery_budget_exhaustion_surfaces_the_error() {
+ // Enough canned 400s that the queue is never the thing that stops the loop;
+ // the fallback response is also a 400-shaped body under this helper only if
+ // queued, so keep the queue generously long.
+ let responses: Vec<(u16, Value)> = (0..40)
+ .map(|_| (400, openai_context_length_error()))
+ .collect();
+ let llm = spawn_capturing_llm_with_status(responses).await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ ("BUZZ_AGENT_MAX_HANDOFFS", "0"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+ let p0 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("always-overflows")}]}),
+ )
+ .await;
+ let r0 = tokio::time::timeout(
+ Duration::from_secs(20),
+ h.recv_until(|v| v["id"] == json!(p0)),
+ )
+ .await
+ .expect("recovery must be bounded — prompt never returned, so the rescue loop is unbounded");
+ assert!(
+ r0.get("error").is_some(),
+ "exhausted recovery must surface the provider error, got: {r0}"
+ );
+ let msg = r0["error"]["message"].as_str().unwrap_or_default();
+ assert!(
+ msg.contains("context"),
+ "surfaced error should be the provider's own context-window error, got: {msg}"
+ );
+ // Discriminate WHICH bound stopped the loop. Both the budget and the prompt
+ // floor produce a surfaced error, so the assertion above passes either way
+ // — and the floor can fire on the first rung without the budget ever being
+ // consumed, which would make this test silently exercise a different
+ // mechanism than its name claims. Pin the budget explicitly.
+ let stderr = h.stderr_text();
+ assert!(
+ stderr.contains("context recovery budget spent"),
+ "the per-run recovery BUDGET must be what stops the loop here, not the prompt floor; \
+ got: {stderr}"
+ );
+ // Corroboration: every rung actually ran a forced handoff.
+ let rungs = stderr
+ .matches("provider reported context overflow; forcing handoff")
+ .count();
+ assert_eq!(
+ rungs, 3,
+ "expected all 3 recovery rungs to be attempted before giving up, saw {rungs} — \
+ stderr={stderr}"
+ );
+ h.shutdown().await;
+}
+
+/// The prompt-budget floor, observed on its own. A context-window 400 on a
+/// SMALL history must refuse to rescue rather than halve toward zero: the
+/// overflow is then dominated by what a handoff cannot shrink (system prompt,
+/// tool schemas, the live user prompt), so shrinking history further would only
+/// issue smaller doomed requests in place of a clear error.
+///
+/// The outcome — a surfaced error — is identical to budget exhaustion, so this
+/// asserts the discriminating evidence instead: the floor log line, and that
+/// ZERO forced handoffs were attempted. Without the floor the ladder would spend
+/// all three rungs summarizing a 40-byte history, which is the behavior this
+/// arm exists to forbid.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn small_history_context_400_refuses_rescue_at_the_prompt_floor() {
+ let responses: Vec<(u16, Value)> = (0..10)
+ .map(|_| (400, openai_context_length_error()))
+ .collect();
+ let llm = spawn_capturing_llm_with_status(responses).await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ ("BUZZ_AGENT_MAX_HANDOFFS", "0"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+ let p0 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"tiny"}]}),
+ )
+ .await;
+ let r0 = tokio::time::timeout(
+ Duration::from_secs(20),
+ h.recv_until(|v| v["id"] == json!(p0)),
+ )
+ .await
+ .expect("must not loop — the floor should stop the rescue immediately");
+ assert!(
+ r0.get("error").is_some(),
+ "a context 400 with no shrinkable history must surface the error, got: {r0}"
+ );
+ let stderr = h.stderr_text();
+ assert!(
+ stderr.contains("below the") && stderr.contains("floor"),
+ "the prompt-budget FLOOR must be what stops this, not the recovery budget; got: {stderr}"
+ );
+ let rungs = stderr
+ .matches("provider reported context overflow; forcing handoff")
+ .count();
+ assert_eq!(
+ rungs, 0,
+ "no rescue should be attempted below the floor, saw {rungs} — stderr={stderr}"
+ );
+ // Exactly one request: the rejected one. No summarize, no retry.
+ let captured = llm.captured.lock().await.len();
+ assert_eq!(
+ captured, 1,
+ "expected no rescue round-trips below the floor, saw {captured} requests"
+ );
+ h.shutdown().await;
+}
+
+/// The recovery ladder must actually SHRINK, not just re-summarize at the size
+/// that was already rejected.
+///
+/// Observed on the summarize request's own body — the only externally visible
+/// consequence of the prompt budget. The rejected completion carried the full
+/// history; the rescue's summarize prompt must be materially smaller. Without
+/// this arm, deleting the halving entirely leaves every other test green: they
+/// assert that a handoff HAPPENED, and a handoff at the rejected size still
+/// happens (it just cannot escape a real overflow, which a stub does not
+/// reproduce).
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn recovery_shrinks_the_summarize_prompt_below_the_rejected_size() {
+ let llm = spawn_capturing_llm_with_status(vec![
+ (400, openai_context_length_error()),
+ (200, openai_text("summary")),
+ (200, openai_text_with_usage("done", 10)),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ ("BUZZ_AGENT_MAX_HANDOFFS", "0"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+ let p0 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("shrink-probe")}]}),
+ )
+ .await;
+ let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
+ assert!(r0.get("error").is_none(), "expected recovery: {r0}");
+
+ let captured = llm.captured.lock().await.clone();
+ assert!(
+ captured.len() >= 2,
+ "expected at least reject + summarize, saw {}",
+ captured.len()
+ );
+ let content_bytes = |req: &Value| -> usize {
+ req["messages"]
+ .as_array()
+ .map(|ms| {
+ ms.iter()
+ .filter_map(|m| m["content"].as_str())
+ .map(str::len)
+ .sum()
+ })
+ .unwrap_or(0)
+ };
+ let rejected = content_bytes(&captured[0]);
+ let summarize = content_bytes(&captured[1]);
+ assert!(
+ rejected > 0 && summarize > 0,
+ "empty measurement is not a result: rejected={rejected} summarize={summarize}"
+ );
+ // Halving from the rejected size lands near 0.5x; 0.75x leaves headroom for
+ // the summarizer's fixed frame while still failing if no shrink happened.
+ assert!(
+ (summarize as f64) < 0.75 * (rejected as f64),
+ "rescue summarize prompt ({summarize} bytes) must be materially smaller than the \
+ rejected request ({rejected} bytes) — the ladder is not shrinking"
+ );
+ h.shutdown().await;
+}
+
+/// The ladder must shrink between RUNGS, not just once on entry.
+///
+/// This arm exists because a mutant that pins `shift` to `1` — deleting the
+/// `attempts` dependence, so every rung rebuilds the same budget — SURVIVED the
+/// whole suite. It had to: `attempts` is 0 on the first rung, so `shift = 1` IS
+/// production there, and every other arm stops at rung 1. The single-rung shrink
+/// arm above cannot see this; only a fixture that forces a SECOND rung can.
+///
+/// The forcing move is the realistic one the ladder was designed for: the
+/// summarize call travels the same provider path, so rung 1's summarize is
+/// itself rejected for context overflow (`Skipped`), and rung 2 must come back
+/// with a materially smaller summarizer prompt.
+///
+/// Budgets: history is ~64 KB, so rung 1 asks for ~32 KB and rung 2 for ~16 KB,
+/// both comfortably above the 4 KiB floor — the floor must not be what
+/// separates them, or this would measure the wrong mechanism.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn recovery_shrinks_further_on_each_rung() {
+ let llm = spawn_capturing_llm_with_status(vec![
+ // 1: the completion that overflows.
+ (400, openai_context_length_error()),
+ // 2: rung-1 summarize, rejected the same way -> Skipped -> next rung.
+ (400, openai_context_length_error()),
+ // 3: rung-2 summarize succeeds.
+ (200, openai_text("summary")),
+ // 4: the retried completion.
+ (200, openai_text_with_usage("done", 10)),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ ("BUZZ_AGENT_MAX_HANDOFFS", "0"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+ let p0 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("rung-shrink-probe")}]}),
+ )
+ .await;
+ let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
+ assert!(
+ r0.get("error").is_none(),
+ "expected recovery on the second rung: {r0}"
+ );
+
+ // The second rung must actually have been taken — otherwise the byte
+ // comparison below would compare rung 1 against the retry.
+ let stderr = h.stderr_text();
+ assert!(
+ stderr.contains("did not run; shrinking further"),
+ "rung 1 must have been Skipped so rung 2 runs; got: {stderr}"
+ );
+ assert!(
+ !stderr.contains("below the"),
+ "the prompt FLOOR must not be involved in this fixture; got: {stderr}"
+ );
+
+ let captured = llm.captured.lock().await.clone();
+ assert_eq!(
+ captured.len(),
+ 4,
+ "expected reject + rung1 summarize + rung2 summarize + retry, saw {}",
+ captured.len()
+ );
+ let content_bytes = |req: &Value| -> usize {
+ req["messages"]
+ .as_array()
+ .map(|ms| {
+ ms.iter()
+ .filter_map(|m| m["content"].as_str())
+ .map(str::len)
+ .sum()
+ })
+ .unwrap_or(0)
+ };
+ let rung1 = content_bytes(&captured[1]);
+ let rung2 = content_bytes(&captured[2]);
+ assert!(
+ rung1 > 0 && rung2 > 0,
+ "empty measurement is not a result: rung1={rung1} rung2={rung2}"
+ );
+ assert!(
+ (rung2 as f64) < 0.75 * (rung1 as f64),
+ "each rung must shrink: rung2 ({rung2} bytes) is not materially smaller than rung1 \
+ ({rung1} bytes) — the budget is not tracking `attempts`"
+ );
+ h.shutdown().await;
+}
+
+/// Gate 5, and the DIRECTION the clearing protects: not a spurious handoff, a
+/// MISSED one. After a reactive reset the stale `last_request_input_tokens`
+/// describes history that no longer exists, and its paired byte baseline
+/// describes the pre-reset (larger) history — so `grown` stays near zero and the
+/// projection collapses to the stale sub-threshold token count. The gate goes
+/// BLIND until history exceeds its pre-reset size.
+///
+/// Constructing the divergence takes three turns, and two of the constraints are
+/// load-bearing — a first attempt with a simpler fixture produced traces
+/// BYTE-IDENTICAL between the fix and its deletion:
+/// * Turn 1 must stay UNDER the gate threshold, or the proactive handoff fires
+/// first and consumes the queue slot the overflow was meant to land in — no
+/// usage is ever recorded, both variants sit at `None`, and the test measures
+/// nothing.
+/// * The post-recovery retry must report NO usage. A usage-bearing response
+/// overwrites both fields with coherent values on the spot, which makes the
+/// clear genuinely redundant and the mutant equivalent. The reachable window
+/// is exactly when the retry omits usage and the stale pair survives.
+/// Turn 3 then carries a large prompt: a cleared baseline falls through to the
+/// byte signal and hands off, while the stale pair projects
+/// `10 + (190KB - 100KB)` = ~90k tokens, under the 180k threshold, and does not.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reactive_reset_clears_usage_baseline_so_the_gate_is_not_blind() {
+ // ~100 KB: under the 180 KB byte-fallback threshold, so turn 1 does NOT
+ // trip the proactive gate, but large enough to be the stale `measured_bytes`
+ // that suppresses `grown` later.
+ let mut medium = String::with_capacity(100 * 1024);
+ medium.push_str("turn-one-medium ");
+ while medium.len() < 100 * 1024 {
+ medium.push_str("padding under the byte fallback threshold. ");
+ }
+ // ~190 KB: over the threshold, so a CLEARED baseline must hand off.
+ let mut big = String::with_capacity(190 * 1024);
+ big.push_str("turn-three-large ");
+ while big.len() < 190 * 1024 {
+ big.push_str("padding to exceed the byte fallback threshold. ");
+ }
+
+ let llm = spawn_capturing_llm_with_status(vec![
+ // Turn 1: succeeds, reporting a SMALL usage reading against a ~100 KB
+ // history. This is the pair that goes stale.
+ (200, openai_text_with_usage("ack-medium", 10)),
+ // Turn 2: the overflow.
+ (400, openai_context_length_error()),
+ // Turn 2: the forced handoff's summarize.
+ (200, openai_text("forced summary")),
+ // Turn 2: the retry — NO usage block, so the baseline is not refreshed.
+ (200, openai_text("recovered, no usage reported")),
+ // Turn 3: with a cleared baseline a gated summarize comes first; with a
+ // stale one this slot is the completion instead. Spares so an exhausted
+ // queue is never what ends a turn.
+ (200, openai_text("gated summary")),
+ (200, openai_text_with_usage("done", 10)),
+ (200, openai_text_with_usage("spare-1", 10)),
+ (200, openai_text_with_usage("spare-2", 10)),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
+ ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ // Must permit a GATED handoff — turn 3 observes the proactive gate,
+ // which a cap of 0 would forbid.
+ ("BUZZ_AGENT_MAX_HANDOFFS", "5"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ // Turn 1: under threshold, records the usage pair.
+ let p0 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text": medium}]}),
+ )
+ .await;
+ let r0 = tokio::time::timeout(
+ Duration::from_secs(25),
+ h.recv_until(|v| v["id"] == json!(p0)),
+ )
+ .await
+ .expect("turn 1 must return");
+ assert!(r0.get("error").is_none(), "turn 1 should succeed: {r0}");
+ assert!(
+ !h.stderr_text().contains("handoff #"),
+ "precondition: turn 1 must NOT hand off, or no usage pair is recorded and this test \
+ measures nothing. stderr={}",
+ h.stderr_text()
+ );
+
+ // Turn 2: small prompt, overflow, reactive recovery.
+ let p1 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"small, overflows"}]}),
+ )
+ .await;
+ let r1 = tokio::time::timeout(
+ Duration::from_secs(25),
+ h.recv_until(|v| v["id"] == json!(p1)),
+ )
+ .await
+ .expect("turn 2 must return");
+ assert!(r1.get("error").is_none(), "turn 2 should recover: {r1}");
+ assert!(
+ h.stderr_text()
+ .contains("provider reported context overflow; forcing handoff"),
+ "precondition: the reactive path must have run in turn 2. stderr={}",
+ h.stderr_text()
+ );
+ let handoffs_after_turn2 = h.stderr_text().matches("handoff #").count();
+
+ // Turn 3: large prompt. A cleared baseline sees it via the byte signal and
+ // hands off; a stale pair under-projects and stays blind.
+ let p2 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text": big}]}),
+ )
+ .await;
+ let r2 = tokio::time::timeout(
+ Duration::from_secs(25),
+ h.recv_until(|v| v["id"] == json!(p2)),
+ )
+ .await
+ .expect("turn 3 must return");
+ assert!(r2.get("error").is_none(), "turn 3 should succeed: {r2}");
+ let stderr = h.stderr_text();
+ let handoffs_after_turn3 = stderr.matches("handoff #").count();
+ assert!(
+ handoffs_after_turn3 > handoffs_after_turn2,
+ "turn 3 must produce a GATED handoff ({handoffs_after_turn2} before, \
+ {handoffs_after_turn3} after): the reactive reset must clear the usage baseline, or the \
+ proactive gate under-projects and stays blind to an oversized history. stderr={stderr}"
+ );
+ h.shutdown().await;
+}
+
+// ─── Tests: per-turn handoff cap semantics ───────────────────────────────────
+
+/// A session that has already performed N handoffs in previous turns must still
+/// compact on subsequent turns — the per-session lifetime kill switch is gone.
+///
+/// Mechanism: the gate fires at the start of each round, comparing
+/// `last_request_input_tokens` (stored by the previous response) against the
+/// token threshold. So:
+/// - Turn 1 complete() returns usage=950 (> threshold=900). Turn ends; usage stored.
+/// - Turn 2 round 0: 950 >= 900 → handoff. post-handoff complete() returns usage=950.
+/// Session `handoff_count` is now 1; `turn_handoff_count` was just reset to 0 at
+/// turn start and is now 1.
+/// - Turn 3 round 0: `turn_handoff_count` resets to 0; session count is 1 but
+/// the gate uses `turn_handoff_count` → cap not reached → handoff fires again.
+///
+/// Without the fix (`handoff_count` compared against cap, never reset):
+/// session count after turn 2 = 1 >= max_handoffs=1 → gate permanently blocked
+/// for all subsequent turns → history grows until provider wall.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn handoff_cap_resets_per_turn_not_per_session() {
+ // LLM call sequence:
+ // req 1: turn 1 complete() → usage=950 (over threshold)
+ // req 2: turn 2 pre-flight summarize → summary text
+ // req 3: turn 2 complete() → usage=950 (re-arms gate for turn 3)
+ // req 4: turn 3 pre-flight summarize → summary text ← cap reset proves this fires
+ // req 5: turn 3 complete() → done
+ let llm = spawn_capturing_llm(vec![
+ openai_text_with_usage("ack-t1", 950), // turn 1: stores high usage
+ openai_text("summary-t2"), // turn 2: pre-flight summarize
+ openai_text_with_usage("done-t2", 950), // turn 2: post-handoff, re-arms gate
+ openai_text("summary-t3"), // turn 3: pre-flight summarize (cap reset)
+ openai_text_with_usage("done-t3", 10), // turn 3: post-handoff complete
+ ])
+ .await;
+
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"),
+ ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"),
+ // Cap of 1 per turn. Before the fix this permanently disables the
+ // gate once session handoff_count reaches 1.
+ ("BUZZ_AGENT_MAX_HANDOFFS", "1"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ // Turn 1: no prior usage; preflight skips (byte-fallback not triggered by
+ // tiny prompt). complete() stores usage=950.
+ let p1 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 1"}]}),
+ )
+ .await;
+ let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
+ assert_eq!(
+ llm.captured.lock().await.len(),
+ 1,
+ "turn 1 must produce exactly 1 LLM request"
+ );
+
+ // Turn 2: 950 >= threshold=900 → handoff fires. Session handoff_count: 1.
+ let p2 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 2"}]}),
+ )
+ .await;
+ let _ = h.recv_until(|v| v["id"] == json!(p2)).await;
+ assert_eq!(
+ llm.captured.lock().await.len(),
+ 3,
+ "turn 2 must produce 2 LLM requests (summarize + complete), 3 total"
+ );
+ let stderr = h.stderr_text();
+ assert!(
+ stderr.contains("handoff #1"),
+ "expected first handoff log after turn 2; got: {stderr}"
+ );
+
+ // Turn 3: turn_handoff_count resets to 0 → gate fires again despite
+ // session handoff_count=1 == cap=1.
+ let p3 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 3"}]}),
+ )
+ .await;
+ let _ = h.recv_until(|v| v["id"] == json!(p3)).await;
+ assert_eq!(
+ llm.captured.lock().await.len(),
+ 5,
+ "turn 3 must also produce 2 LLM requests (per-turn cap reset → handoff fires again), \
+ 5 total"
+ );
+ let stderr = h.stderr_text();
+ assert!(
+ stderr.contains("handoff #2"),
+ "expected second handoff log after turn 3 (cap reset); got: {stderr}"
+ );
+
+ h.shutdown().await;
+}
+
+/// Within a single turn, the per-turn cap still bounds the number of handoffs.
+/// A turn that exceeds `max_handoffs` compaction attempts must emit a WARN and
+/// fall back to truncation — it must NOT compact indefinitely.
+///
+/// Mechanism: with cap=1 and a multi-round turn (tool call in round 1 → round 2),
+/// the pre-flight handoff fires at the start of round 1 (usage from a *previous*
+/// turn is high). After the compaction, the post-handoff complete() in round 1
+/// returns a tool call, causing a second round. Round 2's preflight sees that
+/// turn_handoff_count=1 == max_handoffs=1, so it refuses and emits WARN.
+///
+/// A steer is injected while the run is active to prove that the steer path
+/// does NOT reset `handoff_attempts` — the cap must still fire on round 1 with
+/// no second summarize call.
+///
+/// This test requires a fake MCP server to produce a tool-call round.
+/// It drives via `fake-mcp` — the same binary used in other multi-round tests.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn handoff_cap_binds_within_a_single_turn() {
+ // LLM call sequence in turn 2 (turn 1 seeds the usage):
+ // req 1: turn 1 complete() → usage=950 (over threshold=900)
+ // req 2: turn 2 round 0 summarize() → summary (handoff_attempts: 0→1)
+ // req 3: turn 2 round 0 complete() → tool_call + usage=950 (re-arms gate)
+ // [fake-mcp tool executes; steer queued while run is active]
+ // req 4: turn 2 round 1 preflight → 950 >= 900 AND attempts=1 >= max=1
+ // → WARN, skip (cap exhausted for this turn)
+ // req 5: turn 2 round 1 complete() → end_turn (steer text folded into messages)
+ let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
+ // Build a tool-call response that also carries usage so the gate re-arms
+ // on round 1's preflight (without usage, last_request_input_tokens is None
+ // after the handoff clears it, and the byte-fallback won't fire on tiny history).
+ let tool_call_with_usage = {
+ let mut v = openai_tool_call("tc-1", "test_tool", json!({}));
+ v["usage"] = json!({
+ "prompt_tokens": 950u64,
+ "completion_tokens": 5,
+ "total_tokens": 955,
+ });
+ v
+ };
+ let llm = spawn_capturing_llm(vec![
+ openai_text_with_usage("seed", 950), // turn 1: seed high usage
+ openai_text("handoff-summary"), // turn 2 round 0: summarize
+ tool_call_with_usage, // turn 2 round 0: tool call + usage (re-arms)
+ openai_text_with_usage("end_turn_text", 10), // turn 2 round 1: final answer
+ ])
+ .await;
+
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"),
+ ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"),
+ ("BUZZ_AGENT_MAX_HANDOFFS", "1"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ ],
+ )
+ .await;
+
+ // Init with the fake MCP server so test_tool is available.
+ h.send(
+ "initialize",
+ json!({"protocolVersion":1,"clientCapabilities":{}}),
+ )
+ .await;
+ let _ = h.recv().await;
+ h.send(
+ "session/new",
+ json!({
+ "cwd": "/tmp",
+ "mcpServers": [{
+ "name": "cap_test",
+ "command": fake_mcp,
+ "args": [],
+ "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }],
+ }],
+ }),
+ )
+ .await;
+ let r = h
+ .recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
+ .await;
+ let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
+
+ // Turn 1: seed high usage.
+ let p1 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"seed"}]}),
+ )
+ .await;
+ let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
+
+ // Turn 2: triggers a handoff at round 0, then a tool call, then round 1
+ // where the cap is already exhausted. A steer is injected while the run
+ // is active to prove mid-turn steers cannot reset `handoff_attempts`.
+ let p2 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"do work"}]}),
+ )
+ .await;
+
+ // Drain until the final response, approving tool-permission requests,
+ // capturing the activeRunId once it is broadcast, sending one steer,
+ // and verifying that it is accepted in the live run.
+ let mut run_id: Option = None;
+ let mut steer_id: i64 = -1;
+ let mut steer_accepted = false;
+ loop {
+ let v = h.recv().await;
+
+ // Capture the run id from the first session/update that carries it,
+ // then immediately queue a steer. This must happen before round 1 so
+ // the steer text is present but the cap check still fires — proving
+ // the counter is not reset by the steer path.
+ if run_id.is_none() {
+ if let Some(rid) = v["params"]["update"]["_meta"]["goose"]["activeRunId"].as_str() {
+ run_id = Some(rid.to_owned());
+ steer_id = h
+ .send(
+ "_goose/unstable/session/steer",
+ json!({
+ "sessionId": sid,
+ "expectedRunId": rid,
+ "prompt": [{"type":"text","text":"STEER-CANARY: also consider the edge case"}],
+ }),
+ )
+ .await;
+ }
+ }
+
+ // Steer response: assert it was accepted in the live run.
+ if steer_id >= 0 && v["id"] == json!(steer_id) {
+ assert!(
+ v.get("result").is_some(),
+ "steer must be accepted while the run is active; got: {v}"
+ );
+ assert_eq!(
+ v["result"]["runId"].as_str(),
+ run_id.as_deref(),
+ "steer must reference the live run id"
+ );
+ steer_accepted = true;
+ continue;
+ }
+
+ if v.get("method") == Some(&json!("session/request_permission")) {
+ let id = v["id"].clone();
+ h.write(json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": { "outcome": { "outcome": "selected", "optionId": "allow" } },
+ }))
+ .await;
+ continue;
+ }
+ if v["id"] == json!(p2) {
+ assert!(
+ v.get("result").is_some(),
+ "turn 2 must succeed even when cap blocks round-1 handoff; got: {v}"
+ );
+ break;
+ }
+ }
+
+ assert!(
+ steer_accepted,
+ "steer was never accepted during turn 2; the steer arm is missing coverage"
+ );
+
+ // 4 LLM requests: seed + summarize + tool-call-with-usage + final-complete.
+ let count = llm.captured.lock().await.len();
+ assert_eq!(
+ count, 4,
+ "expected 4 LLM requests (seed + summarize + tool-call + final); got {count}"
+ );
+
+ let stderr = h.stderr_text();
+ assert!(
+ stderr.contains("handoff cap reached"),
+ "expected cap-reached WARN in stderr; got: {stderr}"
+ );
+ assert!(
+ stderr.contains("reason=\"preflight\""),
+ "expected reason=\"preflight\" field in cap WARN; got: {stderr}"
+ );
+ assert!(
+ stderr.contains("handoff_attempts="),
+ "expected handoff_attempts field in cap WARN; got: {stderr}"
+ );
+ assert!(
+ stderr.contains("max_handoffs="),
+ "expected max_handoffs field in cap WARN; got: {stderr}"
+ );
+
+ h.shutdown().await;
+}
+
+/// A failing `summarize()` call must still consume one slot from the per-turn
+/// handoff-attempt budget. Before the fix, `handoff_count` was incremented only
+/// on a successful compaction; a flaky summarizer could be retried indefinitely
+/// within a turn. The fix moves the increment to before `summarize()`.
+///
+/// Proof: with `max_handoffs=1` and a multi-round turn:
+/// - Round 0 preflight: threshold met, attempts: 0→1, summarize() fails → Skipped.
+/// - Round 1 preflight: attempts=1 >= cap=1 → WARN (cap hit despite no successful
+/// compaction). Without the pre-summarize increment, attempts would still be 0
+/// here and a second summarize() would be attempted — the bug.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn failed_summarize_burns_handoff_attempt_budget() {
+ // We need the summarize() call to fail. The summarize path uses the same
+ // fake LLM server; we queue an HTTP error body for the summarize request.
+ // But our spawn_capturing_llm always returns 200, so we use a non-OpenAI-
+ // shaped response that the agent will treat as an error (missing `choices`).
+ //
+ // LLM call sequence:
+ // req 1: turn 1 complete() → usage=950 (seeds the gate)
+ // req 2: turn 2 round 0 summarize() → malformed response (treated as error)
+ // handoff_attempts incremented to 1 BEFORE this
+ // req 3: turn 2 round 0 complete() → tool_call + usage=950 (re-arms gate)
+ // req 4: turn 2 round 1 preflight → cap reached: WARN (attempts=1 >= max=1)
+ // req 5: turn 2 round 1 complete() → end_turn
+ let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
+ let bad_summary_response = json!({ "error": "upstream unavailable" }); // no `choices`
+ let tool_call_with_usage = {
+ let mut v = openai_tool_call("tc-2", "test_tool", json!({}));
+ v["usage"] = json!({
+ "prompt_tokens": 950u64,
+ "completion_tokens": 5,
+ "total_tokens": 955,
+ });
+ v
+ };
+ let llm = spawn_capturing_llm(vec![
+ openai_text_with_usage("seed", 950), // turn 1: seed usage
+ bad_summary_response, // turn 2 round 0: summarize fails
+ tool_call_with_usage, // turn 2 round 0: complete → tool call
+ openai_text_with_usage("done", 10), // turn 2 round 1: final answer
+ ])
+ .await;
+
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"),
+ ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"),
+ ("BUZZ_AGENT_MAX_HANDOFFS", "1"),
+ (
+ "BUZZ_AGENT_MAX_HISTORY_BYTES",
+ &(16 * 1024 * 1024).to_string(),
+ ),
+ ],
+ )
+ .await;
+
+ h.send(
+ "initialize",
+ json!({"protocolVersion":1,"clientCapabilities":{}}),
+ )
+ .await;
+ let _ = h.recv().await;
+ h.send(
+ "session/new",
+ json!({
+ "cwd": "/tmp",
+ "mcpServers": [{
+ "name": "budget_test",
+ "command": fake_mcp,
+ "args": [],
+ "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }],
+ }],
+ }),
+ )
+ .await;
+ let r = h
+ .recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
+ .await;
+ let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
+
+ // Turn 1: seed high usage.
+ let p1 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"seed"}]}),
+ )
+ .await;
+ let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
+
+ // Turn 2: round 0 summarize fails, but attempts was already incremented.
+ // Round 1 preflight must see cap hit and emit WARN.
+ let p2 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"work"}]}),
+ )
+ .await;
+
+ loop {
+ let v = h.recv().await;
+ if v.get("method") == Some(&json!("session/request_permission")) {
+ let id = v["id"].clone();
+ h.write(json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": { "outcome": { "outcome": "selected", "optionId": "allow" } },
+ }))
+ .await;
+ continue;
+ }
+ if v["id"] == json!(p2) {
+ assert!(v.get("result").is_some(), "turn 2 must succeed; got: {v}");
+ break;
+ }
+ }
+
+ let stderr = h.stderr_text();
+ // Round 0: the failed summarize should warn about the failure.
+ assert!(
+ stderr.contains("handoff failed") || stderr.contains("handoff returned empty"),
+ "expected summarize-failure WARN; got: {stderr}"
+ );
+ // Round 1: cap must be hit (attempts=1 from the failed attempt).
+ assert!(
+ stderr.contains("handoff cap reached"),
+ "expected cap-reached WARN after failed summarize burned the attempt; got: {stderr}"
+ );
+
+ h.shutdown().await;
+}
diff --git a/crates/buzz-backend-kubernetes/Cargo.toml b/crates/buzz-backend-kubernetes/Cargo.toml
new file mode 100644
index 0000000000..1cd17030db
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/Cargo.toml
@@ -0,0 +1,36 @@
+[package]
+name = "buzz-backend-kubernetes"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+description = "Kubernetes backend provider for Buzz remote agents (docs/remote-agents.md)"
+
+[[bin]]
+name = "buzz-backend-kubernetes"
+path = "src/main.rs"
+
+[dependencies]
+kube = { workspace = true }
+k8s-openapi = { workspace = true }
+nostr = { workspace = true }
+tokio = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+sha2 = { workspace = true }
+hex = { workspace = true }
+rand = { workspace = true }
+chrono = { workspace = true }
+http = "1"
+http-body-util = "0.1"
+
+# Explicit rustls dep with the ring provider — required to install the
+# process-level CryptoProvider at startup. Without it this binary panics on its
+# first TLS connection to the apiserver: the release build compiles every
+# sidecar in one cargo invocation (.github/workflows/release.yml), which unifies
+# both ring and aws-lc-rs features and leaves rustls unable to auto-select a
+# provider. Same dependency and reason as crates/buzz-cli/Cargo.toml.
+rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
+
+[dev-dependencies]
+tower = { workspace = true }
diff --git a/crates/buzz-backend-kubernetes/src/classify.rs b/crates/buzz-backend-kubernetes/src/classify.rs
new file mode 100644
index 0000000000..e9cfaba402
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/classify.rs
@@ -0,0 +1,377 @@
+//! The deploy state machine (spec §Deploy State Machine), as a pure function.
+//!
+//! `classify` maps a verified observation plus the desired create intent to
+//! one [`Action`]. It performs no I/O, so every row of the spec's table is a
+//! unit test with no cluster. `reconcile` executes actions and re-enters.
+//!
+//! Two invariants are structural rather than remembered:
+//!
+//! * [`Action::Delete`] carries the [`Fence`] from the exact observation that
+//! authorized it. There is no way to build a delete without one, so a later
+//! helper cannot re-read and silently substitute a fresher fence.
+//! * The pull-failure classifier ([`PullFailure`]) reaches only
+//! [`Action::Report`] and [`Action::Observe`]. It is absent from
+//! `Action::Delete`'s type, so "reason strings are never deletion
+//! authority" is enforced by the compiler.
+
+use crate::intent::Fingerprint;
+
+/// The compare-and-delete fence: UID + resourceVersion from the observation
+/// that authorized the deletion. A failed precondition means the object
+/// changed since the read — re-enter, never retry the delete.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Fence {
+ pub uid: String,
+ pub resource_version: String,
+}
+
+/// Why a pod that never started looks permanently broken. Reporting only.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PullFailure {
+ /// Registry auth: a 403/401 `ImagePullBackOff` retries forever without
+ /// ever succeeding, so "the pull retries" is false for this case.
+ Unauthorized,
+ /// The digest or repository does not exist at that registry.
+ ManifestUnknown,
+ /// The image has no variant for the node's architecture.
+ ArchMismatch,
+}
+
+/// The container's startup state, already decoded from pod status. Decoding
+/// happens at the edge so this module stays free of API types.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Startup {
+ /// `state.running` — the harness process is up. This, not pod phase, is
+ /// what "live" means.
+ Started,
+ /// Started once and reached a terminal phase (Succeeded/Failed).
+ Terminated,
+ /// Never started, and self-healing is plausible: unschedulable during
+ /// scale-from-zero, an image pull in progress, a transient
+ /// `CreateContainerConfigError` whose Secret exists.
+ NeverStartedRecoverable,
+ /// Never started, and the provider *verified* the cause — not a reason
+ /// string. Either the referenced Secret is confirmed absent by a
+ /// most-recent read, or the image reference is structurally invalid.
+ NeverStartedProvablyBroken,
+ /// Never started; the pull is failing in a way that will not self-heal.
+ /// Still recoverable in the *never delete* sense — this only changes what
+ /// we report and how long we wait.
+ NeverStartedPullFailing(PullFailure),
+}
+
+/// A pod that passed identity and ownership verification: label-selected,
+/// full-pubkey annotation equal to the derived pubkey, management marker
+/// present. Constructing this type is the verification step's output, so an
+/// unverified object cannot reach `classify` at all.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct VerifiedPod {
+ pub name: String,
+ pub fence: Fence,
+ /// Set once the apiserver accepts a delete. In Kubernetes there is no
+ /// `Terminating` phase — a pod being gracefully deleted stays in phase
+ /// `Running` for its whole grace period — so this must be checked
+ /// *before* startup state or the dying pod reads as the no-op row.
+ pub deletion_marked: bool,
+ pub startup: Startup,
+ /// The `buzz.block.xyz/create-intent` annotation as recorded at create.
+ /// `None` for a pod written before the annotation existed, which counts
+ /// as divergence.
+ pub recorded_intent: Option,
+}
+
+/// What the reconciler should do next.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Action {
+ /// Create the pod, then wait for the harness container to start.
+ Create,
+ /// Compare-and-delete, poll for actual disappearance, then re-enter.
+ Delete { name: String, fence: Fence },
+ /// Wait for a deletion already in flight, then re-enter.
+ AwaitDisappearance { name: String },
+ /// Strict no-op: return this `agent_id`, mutate nothing.
+ NoOp { agent_id: String },
+ /// Keep observing until started or the operation deadline expires; on
+ /// expiry report the latest condition. Never deletes, on this call or any
+ /// later one.
+ Observe { name: String },
+ /// Surface an actionable condition immediately rather than burning the
+ /// deadline on a failure that will not self-heal.
+ Report { name: String, failure: PullFailure },
+}
+
+/// Apply the spec's ordered rules to one verified observation.
+///
+/// `desired` is the freshly computed create intent; comparison is always
+/// recorded-annotation vs freshly-computed, never a diff against the live pod
+/// (admission defaulting would make every pod look divergent).
+pub fn classify(observed: Option<&VerifiedPod>, desired: &Fingerprint) -> Action {
+ let Some(pod) = observed else {
+ // Row: no instance → create. First deploy, or after GC.
+ return Action::Create;
+ };
+
+ // Row: deletion-marked, ANY phase. Checked before startup state because
+ // there is no `Terminating` phase to match on — a gracefully deleting pod
+ // reports phase `Running` throughout its grace period, so testing startup
+ // first would mistake it for the live no-op row and return an id that
+ // evaporates.
+ if pod.deletion_marked {
+ return Action::AwaitDisappearance {
+ name: pod.name.clone(),
+ };
+ }
+
+ match &pod.startup {
+ // Row: live and started → strict no-op. Start must never kill a live
+ // agent mid-turn, whatever the fingerprint says.
+ Startup::Started => Action::NoOp {
+ agent_id: pod.name.clone(),
+ },
+
+ // Row: terminated → delete residue, then re-enter to create. This is
+ // the normal restart path — how a user revives a reaped agent.
+ Startup::Terminated => Action::Delete {
+ name: pod.name.clone(),
+ fence: pod.fence.clone(),
+ },
+
+ // Row: never started, provably non-recoverable → fenced replace.
+ // "Provably" means a verified absence or a structural defect, never a
+ // reason string.
+ Startup::NeverStartedProvablyBroken => Action::Delete {
+ name: pod.name.clone(),
+ fence: pod.fence.clone(),
+ },
+
+ // Inside the recoverable row: a pull that will not self-heal is
+ // reported immediately instead of consuming the 600s deadline. This
+ // changes reporting and wait behavior only — no delete authority.
+ Startup::NeverStartedPullFailing(failure) => Action::Report {
+ name: pod.name.clone(),
+ failure: *failure,
+ },
+
+ // Row: never started, recoverable — split on create-intent
+ // divergence. Divergence is evidence of a config change the user is
+ // waiting on, and it is the *only* thing that replaces a
+ // never-started pod. Pod age triggers nothing: any finite age
+ // threshold collides with Cluster Autoscaler's own pod-age delays,
+ // and delete-recreate resets exactly the age it keys on.
+ Startup::NeverStartedRecoverable => {
+ if pod.recorded_intent.as_ref() == Some(desired) {
+ Action::Observe {
+ name: pod.name.clone(),
+ }
+ } else {
+ Action::Delete {
+ name: pod.name.clone(),
+ fence: pod.fence.clone(),
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn fp(seed: &str) -> Fingerprint {
+ Fingerprint::for_test(seed)
+ }
+
+ fn pod(startup: Startup, intent: Option) -> VerifiedPod {
+ VerifiedPod {
+ name: "buzz-agent-abc123def456".into(),
+ fence: Fence {
+ uid: "uid-1".into(),
+ resource_version: "rv-1".into(),
+ },
+ deletion_marked: false,
+ startup,
+ recorded_intent: intent,
+ }
+ }
+
+ #[test]
+ fn no_instance_creates() {
+ assert_eq!(classify(None, &fp("a")), Action::Create);
+ }
+
+ #[test]
+ fn started_pod_is_strict_no_op() {
+ let p = pod(Startup::Started, Some(fp("a")));
+ assert_eq!(
+ classify(Some(&p), &fp("a")),
+ Action::NoOp {
+ agent_id: p.name.clone()
+ }
+ );
+ }
+
+ /// The asymmetry the spec states plainly: an edit cannot reach a started
+ /// pod until it exits, but it *can* reach a never-started one — the
+ /// never-started pod is the one the user is editing because it did not
+ /// start.
+ #[test]
+ fn started_pod_no_ops_even_when_intent_diverges() {
+ let p = pod(Startup::Started, Some(fp("old")));
+ assert_eq!(
+ classify(Some(&p), &fp("new")),
+ Action::NoOp {
+ agent_id: p.name.clone()
+ }
+ );
+ }
+
+ /// In Kubernetes a gracefully deleting pod stays in phase `Running`. If
+ /// the deletion mark were checked after startup state, this pod would
+ /// take the no-op row and `deploy` would return an id that evaporates.
+ #[test]
+ fn deletion_mark_beats_every_startup_state() {
+ for startup in [
+ Startup::Started,
+ Startup::Terminated,
+ Startup::NeverStartedRecoverable,
+ Startup::NeverStartedProvablyBroken,
+ Startup::NeverStartedPullFailing(PullFailure::Unauthorized),
+ ] {
+ let mut p = pod(startup.clone(), Some(fp("a")));
+ p.deletion_marked = true;
+ assert_eq!(
+ classify(Some(&p), &fp("a")),
+ Action::AwaitDisappearance {
+ name: p.name.clone()
+ },
+ "deletion mark ignored for {startup:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn terminated_pod_is_replaced() {
+ let p = pod(Startup::Terminated, Some(fp("a")));
+ assert_eq!(
+ classify(Some(&p), &fp("a")),
+ Action::Delete {
+ name: p.name.clone(),
+ fence: p.fence.clone()
+ }
+ );
+ }
+
+ /// A never-started winner is repairable: pod exists, Secret confirmed
+ /// absent, container never started. A later deploy must delete-recreate
+ /// rather than no-op — the test that pins started-not-phase as the no-op
+ /// criterion.
+ #[test]
+ fn provably_broken_never_started_pod_is_replaced() {
+ let p = pod(Startup::NeverStartedProvablyBroken, Some(fp("a")));
+ assert_eq!(
+ classify(Some(&p), &fp("a")),
+ Action::Delete {
+ name: p.name.clone(),
+ fence: p.fence.clone()
+ }
+ );
+ }
+
+ /// The anti-livelock rule: identical desired intent means *never* delete,
+ /// however long the pod has been pending. Age is not an input to this
+ /// function at all, which is the strongest way to say so.
+ #[test]
+ fn recoverable_with_matching_intent_only_observes() {
+ let p = pod(Startup::NeverStartedRecoverable, Some(fp("same")));
+ assert_eq!(
+ classify(Some(&p), &fp("same")),
+ Action::Observe {
+ name: p.name.clone()
+ }
+ );
+ }
+
+ /// Repeated identical Starts can never delete anything — the same
+ /// classification, arbitrarily many times.
+ #[test]
+ fn repeated_identical_starts_never_delete() {
+ let p = pod(Startup::NeverStartedRecoverable, Some(fp("same")));
+ for _ in 0..100 {
+ assert!(!matches!(
+ classify(Some(&p), &fp("same")),
+ Action::Delete { .. }
+ ));
+ }
+ }
+
+ /// The wedge escape: the user corrected a resource request or image, so
+ /// the never-started pod is built from configuration they have since
+ /// changed. Without this row the edit could never materialize.
+ #[test]
+ fn recoverable_with_divergent_intent_is_replaced() {
+ let p = pod(Startup::NeverStartedRecoverable, Some(fp("old")));
+ assert_eq!(
+ classify(Some(&p), &fp("new")),
+ Action::Delete {
+ name: p.name.clone(),
+ fence: p.fence.clone()
+ }
+ );
+ }
+
+ /// A pod predating the annotation has no recorded intent — that is
+ /// absence, which the spec groups with divergence.
+ #[test]
+ fn missing_recorded_intent_counts_as_divergence() {
+ let p = pod(Startup::NeverStartedRecoverable, None);
+ assert_eq!(
+ classify(Some(&p), &fp("any")),
+ Action::Delete {
+ name: p.name.clone(),
+ fence: p.fence.clone()
+ }
+ );
+ }
+
+ /// Permanent-looking pull failures report immediately instead of burning
+ /// 600s — and, critically, never delete.
+ #[test]
+ fn pull_failures_report_and_never_delete() {
+ for failure in [
+ PullFailure::Unauthorized,
+ PullFailure::ManifestUnknown,
+ PullFailure::ArchMismatch,
+ ] {
+ let p = pod(Startup::NeverStartedPullFailing(failure), Some(fp("a")));
+ // Divergent intent too — still no delete from this arm.
+ for desired in [fp("a"), fp("different")] {
+ assert_eq!(
+ classify(Some(&p), &desired),
+ Action::Report {
+ name: p.name.clone(),
+ failure
+ }
+ );
+ }
+ }
+ }
+
+ /// Every delete carries the fence from the observation that authorized
+ /// it. Exhaustive over the delete-producing states, so a future arm that
+ /// forgets is caught here rather than in a cluster.
+ #[test]
+ fn every_delete_carries_the_authorizing_fence() {
+ let states = [
+ (Startup::Terminated, fp("a")),
+ (Startup::NeverStartedProvablyBroken, fp("a")),
+ (Startup::NeverStartedRecoverable, fp("divergent")),
+ ];
+ for (startup, desired) in states {
+ let p = pod(startup, Some(fp("a")));
+ match classify(Some(&p), &desired) {
+ Action::Delete { fence, .. } => assert_eq!(fence, p.fence),
+ other => panic!("expected Delete, got {other:?}"),
+ }
+ }
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/client.rs b/crates/buzz-backend-kubernetes/src/client.rs
new file mode 100644
index 0000000000..0c3bed65b7
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/client.rs
@@ -0,0 +1,182 @@
+//! Cluster auth and client construction (spec §Cluster auth,
+//! `docs/remote-agents.md:985-995`).
+//!
+//! Standard kubeconfig resolution (`$KUBECONFIG` → `~/.kube/config`).
+//! `provider_config` carries `context` and `namespace` only — credentials
+//! never transit config (I2, `:196-198`).
+
+use kube::config::{ExecConfig, KubeConfigOptions, Kubeconfig};
+use kube::{Client, Config};
+use std::path::{Path, PathBuf};
+
+/// Directories prepended to `PATH` before the client is built.
+///
+/// Kubeconfigs at Block near-universally authenticate through `exec`
+/// credential plugins (`aws eks get-token`, `gke-gcloud-auth-plugin`) that
+/// resolve via `PATH` — and this provider inherits a Finder-launched
+/// desktop's minimal `PATH`, which contains none of the places those plugins
+/// install to (`:989-994`).
+const PATH_PREPEND: [&str; 2] = ["/opt/homebrew/bin", "/usr/local/bin"];
+
+/// Compute the new `PATH` value: plugin directories first, inherited entries
+/// after, in order. Pure so the ordering can be tested without mutating the
+/// process's environment.
+fn prepended_path(home: Option<&Path>, existing: &std::ffi::OsStr) -> Option {
+ let mut dirs: Vec = PATH_PREPEND.iter().map(PathBuf::from).collect();
+ if let Some(home) = home {
+ dirs.push(home.join(".local/bin"));
+ }
+ // An empty inherited PATH splits into one empty entry, which POSIX
+ // resolves as the current directory — a place a credential plugin should
+ // never be looked up. Drop empties rather than propagate them.
+ dirs.extend(std::env::split_paths(existing).filter(|p| !p.as_os_str().is_empty()));
+ std::env::join_paths(dirs).ok()
+}
+
+/// Prepend the plugin directories to this process's `PATH`.
+///
+/// Modifies the provider's own environment, which is sound here: one process
+/// per operation, called before any client or task exists, and the child
+/// processes that read it are exactly the credential plugins this exists for.
+fn prepend_plugin_path() {
+ let home = std::env::var_os("HOME");
+ let existing = std::env::var_os("PATH").unwrap_or_default();
+ if let Some(joined) = prepended_path(home.as_ref().map(Path::new), &existing) {
+ std::env::set_var("PATH", joined);
+ }
+}
+
+/// Is `command` runnable — an executable on `PATH`, or an existing path?
+fn resolves_on_path(command: &str) -> bool {
+ if command.contains(std::path::MAIN_SEPARATOR) {
+ return Path::new(command).is_file();
+ }
+ std::env::var_os("PATH")
+ .map(|path| std::env::split_paths(&path).any(|dir| dir.join(command).is_file()))
+ .unwrap_or(false)
+}
+
+/// The exec plugin the selected context authenticates with, if any.
+///
+/// Read from the kubeconfig directly rather than from `Config`, which does not
+/// expose it. A read failure yields `None`: this lookup exists only to improve
+/// an error message, and must never be the thing that fails a deploy.
+fn exec_plugin_for(context: Option<&str>) -> Option {
+ let kubeconfig = Kubeconfig::read().ok()?;
+ let context_name = context
+ .map(str::to_string)
+ .or_else(|| kubeconfig.current_context.clone())?;
+ let user_name = kubeconfig
+ .contexts
+ .iter()
+ .find(|c| c.name == context_name)
+ .and_then(|c| c.context.as_ref())
+ .and_then(|c| c.user.clone())?;
+ kubeconfig
+ .auth_infos
+ .iter()
+ .find(|a| a.name == user_name)
+ .and_then(|a| a.auth_info.as_ref())
+ .and_then(|a| a.exec.clone())
+}
+
+/// Turn a client-construction failure into an error a user can act on.
+///
+/// When the context authenticates through an exec plugin that is not on
+/// `PATH`, that is almost always the cause, and the actionable fact is the
+/// plugin's name — not a kube-rs error chain (`:994-995`).
+fn explain(context: Option<&str>, error: &kube::Error) -> String {
+ if let Some(command) = exec_plugin_for(context).and_then(|e| e.command) {
+ if !resolves_on_path(&command) {
+ return format!(
+ "kubeconfig context {} authenticates with the credential plugin \
+ {command:?}, which is not on PATH. Install it or add its \
+ directory to PATH, then try again.",
+ context.unwrap_or("(current)")
+ );
+ }
+ }
+ format!(
+ "could not connect to the cluster using kubeconfig context {}: {error}",
+ context.unwrap_or("(current)")
+ )
+}
+
+/// Build a client for the selected context.
+pub async fn connect(context: Option<&str>) -> Result {
+ prepend_plugin_path();
+
+ let options = KubeConfigOptions {
+ context: context.map(str::to_string),
+ ..Default::default()
+ };
+ let config = Config::from_kubeconfig(&options).await.map_err(|e| {
+ // A named context that does not exist is a user typo, and the
+ // kube-rs message for it is already specific.
+ format!(
+ "could not load kubeconfig for context {}: {e}",
+ context.unwrap_or("(current)")
+ )
+ })?;
+
+ Client::try_from(config).map_err(|e| explain(context, &e))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The three plugin directories must end up ahead of the inherited PATH,
+ /// or a Finder-launched desktop never finds `aws`/`gke-gcloud-auth-plugin`.
+ /// Tested on the pure computation: mutating the process PATH here would
+ /// race every other test in the binary.
+ #[test]
+ fn plugin_directories_are_prepended_in_order() {
+ let joined = prepended_path(
+ Some(Path::new("/tmp/fake-home")),
+ std::ffi::OsStr::new("/inherited/bin:/usr/bin"),
+ )
+ .unwrap();
+ let dirs: Vec = std::env::split_paths(&joined).collect();
+ assert_eq!(
+ dirs,
+ [
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ "/tmp/fake-home/.local/bin",
+ "/inherited/bin",
+ "/usr/bin",
+ ]
+ .map(PathBuf::from)
+ );
+ }
+
+ /// No `HOME` is not a failure — the two absolute directories still apply.
+ #[test]
+ fn missing_home_still_prepends_the_absolute_directories() {
+ let joined = prepended_path(None, std::ffi::OsStr::new("/inherited/bin")).unwrap();
+ let dirs: Vec = std::env::split_paths(&joined).collect();
+ assert_eq!(
+ dirs,
+ ["/opt/homebrew/bin", "/usr/local/bin", "/inherited/bin"].map(PathBuf::from)
+ );
+ }
+
+ /// An empty inherited PATH must not produce an empty entry, which the
+ /// shell and `resolves_on_path` would both read as the cwd.
+ #[test]
+ fn empty_inherited_path_yields_no_empty_entry() {
+ let joined = prepended_path(None, std::ffi::OsStr::new("")).unwrap();
+ let dirs: Vec = std::env::split_paths(&joined).collect();
+ assert_eq!(
+ dirs,
+ ["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from)
+ );
+ }
+
+ #[test]
+ fn resolves_absolute_paths_directly() {
+ assert!(resolves_on_path("/bin/sh"));
+ assert!(!resolves_on_path("/nonexistent/plugin-binary"));
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/cluster.rs b/crates/buzz-backend-kubernetes/src/cluster.rs
new file mode 100644
index 0000000000..755f41f6c8
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/cluster.rs
@@ -0,0 +1,427 @@
+//! The real [`Substrate`]: kube-rs against a live apiserver.
+//!
+//! Everything that *decides* lives in `classify`/`gc`; this module only
+//! performs I/O and maps apiserver responses onto the trait's vocabulary.
+//! Three mappings here are normative rather than incidental:
+//!
+//! * **409 is discriminated on `Status.reason`, never on the code.** A create
+//! 409 is `AlreadyExists`; a delete 409 from a failed precondition is
+//! `Conflict`. Branching on `code == 409` conflates a lost create race with
+//! a stale fence and is the trap the spec names (`:780-794`).
+//! * **Reads leave `resourceVersion` unset**, which is the quorum read. `"0"`
+//! is the cache read, and a confirmed absence from a cache is proof of
+//! nothing (`:761-769`).
+//! * **Deletes never set `grace_period_seconds`**, so the object's own 60s
+//! budget applies. Passing `0` is a force-kill that discards the shutdown
+//! window the pod declares (`:1185-1189`).
+
+use crate::classify::Fence;
+use crate::reconcile::{CreateOutcome, DeleteOutcome, Substrate};
+use chrono::{DateTime, Utc};
+use k8s_openapi::api::core::v1::{Namespace, Pod, Secret};
+use kube::api::{Api, DeleteParams, GetParams, ListParams, PostParams, Preconditions};
+use kube::core::ErrorResponse;
+use kube::{Client, Resource};
+use std::time::{Duration, Instant};
+
+/// `Status.reason` values we branch on. Spelled once so the two 409 arms are
+/// visibly the same discriminator read two ways.
+///
+/// These are wire strings `apimachinery` chooses, not names this crate picks:
+/// `StatusReasonAlreadyExists`, `StatusReasonConflict`, `StatusReasonNotFound`,
+/// and `StatusReasonForbidden` in `k8s.io/apimachinery/pkg/apis/meta/v1/types.go`.
+/// kube-core types `ErrorResponse::reason` as a bare `String`, so there is no
+/// upstream constant to bind to and the spelling is pinned by test instead.
+const REASON_ALREADY_EXISTS: &str = "AlreadyExists";
+const REASON_CONFLICT: &str = "Conflict";
+const REASON_NOT_FOUND: &str = "NotFound";
+const REASON_FORBIDDEN: &str = "Forbidden";
+
+/// The apiserver-backed substrate for one deploy operation.
+pub struct Cluster {
+ client: Client,
+ namespace: String,
+ /// Start of *this operation*, for the deadline. Monotonic: the 600s budget
+ /// must not move when the wall clock does.
+ started: Instant,
+}
+
+/// The typed API error underneath a `kube::Error`, if it is one.
+fn api_error(error: &kube::Error) -> Option<&ErrorResponse> {
+ match error {
+ kube::Error::Api(response) => Some(response),
+ _ => None,
+ }
+}
+
+/// Does this error carry the given `Status.reason`?
+fn reason_is(error: &kube::Error, reason: &str) -> bool {
+ api_error(error).is_some_and(|e| e.reason == reason)
+}
+
+impl Cluster {
+ pub fn new(client: Client, namespace: &str) -> Self {
+ Self {
+ client,
+ namespace: namespace.to_string(),
+ started: Instant::now(),
+ }
+ }
+
+ fn pods(&self) -> Api {
+ Api::namespaced(self.client.clone(), &self.namespace)
+ }
+
+ fn secrets(&self) -> Api {
+ Api::namespaced(self.client.clone(), &self.namespace)
+ }
+
+ /// List an object kind through the raw client so the response's HTTP
+ /// `Date` header is reachable.
+ ///
+ /// `Api::list` returns only the decoded body, and the apiserver's clock is
+ /// the *only* clock the orphan-Secret age gate may use — a desktop's local
+ /// clock running fast computes every in-flight Secret as expired
+ /// (`:1321-1335`). So the list goes through `Client::send`, which hands
+ /// back the whole `http::Response`.
+ async fn list_with_date(
+ &self,
+ selector: &str,
+ ) -> Result<(Vec, Option>), String>
+ where
+ K: Resource
+ + Clone
+ + serde::de::DeserializeOwned
+ + std::fmt::Debug,
+ K::DynamicType: Default,
+ {
+ let dt = K::DynamicType::default();
+ let url = K::url_path(&dt, Some(&self.namespace));
+ // resourceVersion deliberately unset: quorum read.
+ let params = ListParams {
+ label_selector: Some(selector.to_string()),
+ ..Default::default()
+ };
+ let request = kube::core::Request::new(url)
+ .list(¶ms)
+ .map_err(|e| format!("could not build a list request: {e}"))?;
+ let (parts, body) = request.into_parts();
+ let response = self
+ .client
+ .send(http::Request::from_parts(parts, body.into()))
+ .await
+ .map_err(|e| format!("could not list {}: {e}", K::plural(&dt)))?;
+
+ // Parsed before the body is consumed, and independently of it: a
+ // missing or malformed header is not a list failure, it just means the
+ // orphan sweep has no clock and skips.
+ let server_now = response
+ .headers()
+ .get(http::header::DATE)
+ .and_then(|v| v.to_str().ok())
+ .and_then(|v| DateTime::parse_from_rfc2822(v).ok())
+ .map(|v| v.with_timezone(&Utc));
+
+ let bytes = http_body_util::BodyExt::collect(response.into_body())
+ .await
+ .map_err(|e| format!("could not read the {} list body: {e}", K::plural(&dt)))?
+ .to_bytes();
+ let list: kube::core::ObjectList = serde_json::from_slice(&bytes)
+ .map_err(|e| format!("could not decode the {} list: {e}", K::plural(&dt)))?;
+
+ Ok((list.items, server_now))
+ }
+}
+
+impl Substrate for Cluster {
+ async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> {
+ let api: Api = Api::all(self.client.clone());
+ if api
+ .get_opt(namespace)
+ .await
+ .map_err(|e| format!("could not check whether namespace {namespace} exists: {e}"))?
+ .is_some()
+ {
+ return Ok(());
+ }
+
+ let spec = Namespace {
+ metadata: kube::core::ObjectMeta {
+ name: Some(namespace.to_string()),
+ ..Default::default()
+ },
+ ..Default::default()
+ };
+ match api.create(&PostParams::default(), &spec).await {
+ Ok(_) => Ok(()),
+ // Someone else created it between our check and our create. That
+ // is the desired end state, not a failure.
+ Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(()),
+ // Namespace-create is frequently denied on shared clusters. Name
+ // the exact command an operator runs, and never silently fall back
+ // to `default` — deploying an agent into someone else's namespace
+ // is worse than refusing (`:1002-1005`).
+ Err(e) if reason_is(&e, REASON_FORBIDDEN) => Err(format!(
+ "not authorized to create namespace {namespace}. Ask a cluster \
+ administrator to run `kubectl create namespace {namespace}`, \
+ then try again."
+ )),
+ Err(e) => Err(format!("could not create namespace {namespace}: {e}")),
+ }
+ }
+
+ async fn list_pods(&self, selector: &str) -> Result<(Vec, Option>), String> {
+ self.list_with_date::(selector).await
+ }
+
+ async fn list_secrets(&self, selector: &str) -> Result, String> {
+ Ok(self.list_with_date::(selector).await?.0)
+ }
+
+ async fn secret_exists(&self, name: &str) -> Result {
+ // `GetParams::default()` leaves resourceVersion unset — the quorum
+ // read this check requires to be proof of anything.
+ match self.secrets().get_with(name, &GetParams::default()).await {
+ Ok(_) => Ok(true),
+ Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(false),
+ Err(e) => Err(format!("could not check whether secret {name} exists: {e}")),
+ }
+ }
+
+ async fn create_secret(&self, secret: &Secret) -> Result<(), String> {
+ let name = secret.metadata.name.clone().unwrap_or_default();
+ self.secrets()
+ .create(&PostParams::default(), secret)
+ .await
+ .map(|_| ())
+ .map_err(|e| format!("could not create secret {name}: {e}"))
+ }
+
+ async fn create_pod(&self, pod: &Pod) -> Result {
+ let name = pod.metadata.name.clone().unwrap_or_default();
+ match self.pods().create(&PostParams::default(), pod).await {
+ Ok(_) => Ok(CreateOutcome::Created),
+ // The deterministic name is taken: a concurrent attempt won the
+ // election. Discriminated on the reason — a 409 whose reason is
+ // `Conflict` is a different condition and must not be read as a
+ // lost race.
+ Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(CreateOutcome::AlreadyExists),
+ Err(e) => Err(format!("could not create pod {name}: {e}")),
+ }
+ }
+
+ async fn delete_pod(&self, name: &str, fence: &Fence) -> Result {
+ let params = DeleteParams {
+ preconditions: Some(Preconditions {
+ uid: Some(fence.uid.clone()),
+ resource_version: Some(fence.resource_version.clone()),
+ }),
+ // grace_period_seconds deliberately unset: the pod's own 60s
+ // budget applies.
+ ..Default::default()
+ };
+ match self.pods().delete(name, ¶ms).await {
+ Ok(_) => Ok(DeleteOutcome::Accepted),
+ Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(DeleteOutcome::NotFound),
+ // The object changed since the observation that authorized this
+ // delete. Same HTTP code as the create race above, different
+ // reason, different meaning.
+ Err(e) if reason_is(&e, REASON_CONFLICT) => Ok(DeleteOutcome::PreconditionFailed),
+ Err(e) => Err(format!("could not delete pod {name}: {e}")),
+ }
+ }
+
+ async fn delete_secret(&self, name: &str) -> Result<(), String> {
+ match self.secrets().delete(name, &DeleteParams::default()).await {
+ Ok(_) => Ok(()),
+ // Already gone is the desired end state.
+ Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(()),
+ Err(e) => Err(format!("could not delete secret {name}: {e}")),
+ }
+ }
+
+ async fn get_pod(&self, name: &str) -> Result, String> {
+ match self.pods().get_with(name, &GetParams::default()).await {
+ Ok(pod) => Ok(Some(pod)),
+ Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(None),
+ Err(e) => Err(format!("could not read pod {name}: {e}")),
+ }
+ }
+
+ async fn sleep(&self, duration: Duration) {
+ tokio::time::sleep(duration).await;
+ }
+
+ fn elapsed(&self) -> Duration {
+ self.started.elapsed()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use http::Response;
+ use kube::client::Body;
+ use std::sync::{Arc, Mutex};
+ use tower::service_fn;
+
+ fn list_response(date: Option<&str>) -> Response {
+ let mut response = Response::builder().status(200);
+ if let Some(date) = date {
+ response = response.header(http::header::DATE, date);
+ }
+ response
+ .body(Body::from(
+ serde_json::to_vec(&serde_json::json!({
+ "apiVersion": "v1",
+ "kind": "PodList",
+ "metadata": {"resourceVersion": "17"},
+ "items": [{
+ "apiVersion": "v1",
+ "kind": "Pod",
+ "metadata": {"name": "sprig"},
+ "spec": {"containers": [{"name": "agent", "image": "example.invalid/sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}
+ }]
+ }))
+ .unwrap(),
+ ))
+ .unwrap()
+ }
+
+ async fn list_through_real_request_path(
+ date: Option<&'static str>,
+ ) -> (Vec, Option>, String) {
+ let observed_uri = Arc::new(Mutex::new(None));
+ let service_uri = Arc::clone(&observed_uri);
+ let service = service_fn(move |request: http::Request| {
+ let service_uri = Arc::clone(&service_uri);
+ async move {
+ *service_uri.lock().unwrap() = Some(request.uri().to_string());
+ Ok::<_, std::convert::Infallible>(list_response(date))
+ }
+ });
+ let cluster = Cluster::new(Client::new(service, "ignored"), "owned-ns");
+ let result = cluster
+ .list_with_date::("app.kubernetes.io/managed-by=buzz-backend-kubernetes")
+ .await
+ .unwrap();
+ let uri = observed_uri.lock().unwrap().take().unwrap();
+ (result.0, result.1, uri)
+ }
+
+ /// Exercise the shipped `Request` + `Client::send` seam. A fake
+ /// reconciler would not prove that kube-rs emits a quorum list request or
+ /// that the apiserver's clock survives body decoding.
+ #[tokio::test]
+ async fn list_with_date_uses_a_quorum_request_and_returns_the_server_clock() {
+ let (pods, server_now, uri) =
+ list_through_real_request_path(Some("Sun, 02 Aug 2026 04:00:00 GMT")).await;
+
+ assert_eq!(pods.len(), 1, "fixture must contain one decoded pod");
+ assert_eq!(pods[0].metadata.name.as_deref(), Some("sprig"));
+ assert!(uri.starts_with("/api/v1/namespaces/owned-ns/pods?"));
+ assert!(
+ uri.contains("labelSelector=app.kubernetes.io%2Fmanaged-by%3Dbuzz-backend-kubernetes")
+ );
+ assert!(
+ !uri.contains("resourceVersion"),
+ "cache read leaked into {uri}"
+ );
+ assert_eq!(
+ server_now.unwrap().to_rfc3339(),
+ "2026-08-02T04:00:00+00:00"
+ );
+ }
+
+ /// Header failure is deliberately not list failure: without a trustworthy
+ /// apiserver clock the orphan sweep skips, but normal reconciliation still
+ /// receives the decoded objects.
+ #[tokio::test]
+ async fn list_with_date_keeps_items_when_the_server_clock_is_unusable() {
+ for date in [Some("not a date"), None] {
+ let (pods, server_now, _) = list_through_real_request_path(date).await;
+ assert_eq!(pods.len(), 1, "fixture must contain one decoded pod");
+ assert!(server_now.is_none(), "unexpected clock for {date:?}");
+ }
+ }
+
+ /// A typed apiserver error, as kube-rs surfaces it.
+ fn api(reason: &str, code: u16) -> kube::Error {
+ kube::Error::Api(ErrorResponse {
+ status: "Failure".into(),
+ message: String::new(),
+ reason: reason.into(),
+ code,
+ })
+ }
+
+ /// The one discriminator the whole file rests on, and the trap the spec
+ /// predicts: "an implementation that branches on the code alone will
+ /// eventually take the adoption path on a failed delete or vice versa"
+ /// (`:788-790`).
+ ///
+ /// Both of these are 409. Reading the *code* makes them identical; reading
+ /// `Status.reason` keeps a lost create race and a stale fence apart. The
+ /// mutation that must fail this test is `e.reason == …` → `e.code == 409`,
+ /// which no other test in the crate would catch — the fakes never produce
+ /// a real `kube::Error`.
+ #[test]
+ fn the_two_409s_are_never_conflated() {
+ let already_exists = api(REASON_ALREADY_EXISTS, 409);
+ let conflict = api(REASON_CONFLICT, 409);
+
+ assert!(reason_is(&already_exists, REASON_ALREADY_EXISTS));
+ assert!(reason_is(&conflict, REASON_CONFLICT));
+ // The cross terms are the whole point.
+ assert!(!reason_is(&already_exists, REASON_CONFLICT));
+ assert!(!reason_is(&conflict, REASON_ALREADY_EXISTS));
+ }
+
+ /// A transport-level failure is not an apiserver verdict. It must fall
+ /// through to the error arm rather than being read as any reason — a
+ /// connection reset silently classified as `NotFound` would report a pod
+ /// as confirmed-absent, which the classifier treats as proof.
+ #[test]
+ fn a_non_api_error_carries_no_reason() {
+ let transport = kube::Error::LinesCodecMaxLineLengthExceeded;
+ assert!(api_error(&transport).is_none());
+ for reason in [
+ REASON_ALREADY_EXISTS,
+ REASON_CONFLICT,
+ REASON_NOT_FOUND,
+ REASON_FORBIDDEN,
+ ] {
+ assert!(!reason_is(&transport, reason), "matched {reason}");
+ }
+ }
+
+ /// `reason` is `#[serde(default)]` in kube-core, so an apiserver that
+ /// omits it yields an empty string. That must match nothing rather than
+ /// matching an empty pattern by accident.
+ #[test]
+ fn an_absent_reason_matches_nothing() {
+ let bare = api("", 409);
+ assert!(!reason_is(&bare, REASON_ALREADY_EXISTS));
+ assert!(!reason_is(&bare, REASON_CONFLICT));
+ }
+
+ /// The consts are the apiserver's spelling, asserted against literals
+ /// rather than against themselves.
+ ///
+ /// Every other test here references the consts symbolically on both sides
+ /// — fixture *and* assertion — which is true for any pair of distinct
+ /// values. That tests the discriminator is self-consistent, not that it is
+ /// correct: swapping the two 409 values inverts `AlreadyExists` and
+ /// `Conflict` at a real apiserver (`:788-790`'s failure, reached by
+ /// editing a string instead of a branch) with every other test still
+ /// green. These are `apimachinery`'s wire strings and kube-core exposes no
+ /// constant for them, so a literal is the only external anchor available.
+ /// Found by Quinn's mutation matrix; M2/M3/M4 survived without it.
+ #[test]
+ fn the_reason_consts_are_the_apiservers_spelling() {
+ assert_eq!(REASON_ALREADY_EXISTS, "AlreadyExists");
+ assert_eq!(REASON_CONFLICT, "Conflict");
+ assert_eq!(REASON_NOT_FOUND, "NotFound");
+ assert_eq!(REASON_FORBIDDEN, "Forbidden");
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/config.rs b/crates/buzz-backend-kubernetes/src/config.rs
new file mode 100644
index 0000000000..4d96735b7b
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/config.rs
@@ -0,0 +1,470 @@
+//! `provider_config` parsing and the `info` config schema
+//! (spec §`provider_config` v1 fields, `docs/remote-agents.md:1384-1389`).
+//!
+//! Nine fields, all optional except `image` (required at parse time; the
+//! schema offers the published sprig image as a prefill default — §Image).
+//! No credential field exists, by I2: cluster auth comes from ambient
+//! kubeconfig resolution and nothing else (`:196-198`).
+
+use crate::image::{self, ImageRef};
+
+/// Resource requests and limits (§Pod shape: 1cpu/2Gi → 2cpu/4Gi, all four
+/// configurable — `cargo build` in an agent workspace makes 500m/1Gi
+/// unrealistic).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Resources {
+ pub cpu_request: String,
+ pub memory_request: String,
+ pub cpu_limit: String,
+ pub memory_limit: String,
+}
+
+impl Default for Resources {
+ fn default() -> Self {
+ Self {
+ cpu_request: "1".into(),
+ memory_request: "2Gi".into(),
+ cpu_limit: "2".into(),
+ memory_limit: "4Gi".into(),
+ }
+ }
+}
+
+/// Default inactivity budget: the I5 opt-in (§Auto-Stop). The config field and
+/// `BUZZ_ACP_EXIT_AFTER_INACTIVITY` are one knob, not two.
+pub const DEFAULT_INACTIVITY_SECONDS: u64 = 7200;
+
+/// Default `image` schema prefill: the published sprig image, in tag+digest
+/// form so the tag stays human-traceable to its git SHA while the digest does
+/// the pinning (§Image — tag-only refs are rejected; `image::parse` drops the
+/// tag on normalization). This is a UI prefill, not a baked fallback: `image`
+/// stays required, an empty value still fails closed, and the value always
+/// arrives explicitly in `provider_config`, so create-intent fingerprints are
+/// unaffected by provider upgrades.
+pub const DEFAULT_IMAGE: &str = "ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76";
+
+/// Fixed nonzero UID/GID for the agent container (§Pod shape hardening).
+pub const RUN_AS_UID: i64 = 10001;
+pub const RUN_AS_GID: i64 = 10001;
+
+/// Writable workspace root; also `HOME` and the harness's cwd
+/// (§Working directory).
+pub const WORKSPACE_PATH: &str = "/home/agent";
+
+/// `terminationGracePeriodSeconds` — a declared budget, not a derived sum
+/// (§Pod shape). Kubernetes' default 30s would SIGKILL the harness mid-drain.
+pub const TERMINATION_GRACE_SECONDS: i64 = 60;
+
+/// The only restart policy v1 ships. `OnFailure` is double-gated on the
+/// harness exit-code contract *and* a crash-loop classification row the state
+/// machine does not have (`:1121-1139`); until both land the provider refuses
+/// the combination rather than shipping against an undefended convention.
+pub const RESTART_POLICY: &str = "Never";
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProviderConfig {
+ /// kubeconfig context; `None` uses the current context.
+ pub context: Option,
+ pub namespace: String,
+ pub image: ImageRef,
+ pub resources: Resources,
+ /// `None` when `inactivity_seconds` was 0 — refused in v1, see [`parse`].
+ pub inactivity_seconds: Option,
+ pub service_account: Option,
+}
+
+/// Read an optional non-empty string field. Rejects non-string scalars rather
+/// than stringifying them, so a mistyped field is named at the boundary.
+fn optional_string(cfg: &serde_json::Value, field: &str) -> Result, String> {
+ match cfg.get(field) {
+ None | Some(serde_json::Value::Null) => Ok(None),
+ Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None),
+ Some(serde_json::Value::String(s)) => Ok(Some(s.trim().to_string())),
+ Some(other) => Err(format!(
+ "provider_config.{field} must be a string, got {other}"
+ )),
+ }
+}
+
+/// Read an optional unsigned integer. The desktop's form omits blank numeric
+/// fields rather than sending `""`, but a hand-crafted payload may send a
+/// numeric string — accept both, refuse anything else.
+fn optional_u64(cfg: &serde_json::Value, field: &str) -> Result , String> {
+ match cfg.get(field) {
+ None | Some(serde_json::Value::Null) => Ok(None),
+ Some(serde_json::Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| {
+ format!("provider_config.{field} must be a non-negative integer, got {n}")
+ }),
+ Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None),
+ Some(serde_json::Value::String(s)) => s.trim().parse::().map(Some).map_err(|_| {
+ format!("provider_config.{field} must be a non-negative integer, got {s:?}")
+ }),
+ Some(other) => Err(format!(
+ "provider_config.{field} must be a non-negative integer, got {other}"
+ )),
+ }
+}
+
+/// A Kubernetes namespace name: RFC 1123 label, ≤63 chars. Validated here so a
+/// typo fails with a named field instead of an apiserver rejection partway
+/// through a deploy.
+fn valid_namespace(name: &str) -> bool {
+ !name.is_empty()
+ && name.len() <= 63
+ && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
+ && name.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
+ && name
+ .chars()
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
+}
+
+pub fn parse(cfg: &serde_json::Value) -> Result {
+ if !cfg.is_object() && !cfg.is_null() {
+ return Err("provider_config must be a JSON object".to_string());
+ }
+
+ let namespace = optional_string(cfg, "namespace")?.ok_or_else(|| {
+ "provider_config.namespace is required: the info schema supplies a \
+ generated default, so an empty value means the form was cleared"
+ .to_string()
+ })?;
+ if !valid_namespace(&namespace) {
+ return Err(format!(
+ "provider_config.namespace {namespace:?} is not a valid Kubernetes \
+ namespace (lowercase alphanumerics and '-', ≤63 characters)"
+ ));
+ }
+
+ let image = image::parse(optional_string(cfg, "image")?.unwrap_or_default().as_str())?;
+
+ let defaults = Resources::default();
+ let resources = Resources {
+ cpu_request: optional_string(cfg, "cpu_request")?.unwrap_or(defaults.cpu_request),
+ memory_request: optional_string(cfg, "memory_request")?.unwrap_or(defaults.memory_request),
+ cpu_limit: optional_string(cfg, "cpu_limit")?.unwrap_or(defaults.cpu_limit),
+ memory_limit: optional_string(cfg, "memory_limit")?.unwrap_or(defaults.memory_limit),
+ };
+
+ // `inactivity_seconds: 0` is a legal, blessed value in the spec (§Auto-Stop)
+ // meaning "no auto-stop" — but it selects `restartPolicy: OnFailure`, which
+ // §Pod shape forbids until the harness exit-code contract is pinned AND the
+ // state machine gains a crash-loop row. Refusing the *combination* is what
+ // the spec asks for; silently downgrading to `Never` would ship an
+ // indefinite agent that dies on its first crash.
+ let inactivity_seconds = match optional_u64(cfg, "inactivity_seconds")? {
+ None => Some(DEFAULT_INACTIVITY_SECONDS),
+ Some(0) => {
+ return Err(
+ "provider_config.inactivity_seconds: 0 (indefinite lifetime) is not \
+ supported in this version: it requires restartPolicy OnFailure, \
+ which is gated on the harness exit-code contract. Set a positive \
+ number of seconds."
+ .to_string(),
+ )
+ }
+ Some(n) => Some(n),
+ };
+
+ Ok(ProviderConfig {
+ context: optional_string(cfg, "context")?,
+ namespace,
+ image,
+ resources,
+ inactivity_seconds,
+ service_account: optional_string(cfg, "service_account")?,
+ })
+}
+
+/// A fresh `buzz-agents-` namespace default.
+///
+/// Computed per `info` call, which is how "random default" is satisfied with
+/// zero UI changes: the schema's `default` prefills the form (§K8s Namespace).
+pub fn generated_namespace() -> String {
+ use rand::RngExt;
+ const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
+ let mut rng = rand::rng();
+ let suffix: String = (0..6)
+ .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
+ .collect();
+ format!("buzz-agents-{suffix}")
+}
+
+/// The `config_schema` returned by `info`. Drives the UI form:
+/// `properties[*].default` prefill, scalar coercion, `required` gating
+/// (`:407-411`).
+pub fn config_schema() -> serde_json::Value {
+ let defaults = Resources::default();
+ serde_json::json!({
+ "type": "object",
+ "properties": {
+ "context": {
+ "type": "string",
+ "title": "Kubeconfig context",
+ "description": "Context from your kubeconfig. Leave empty to use the current context."
+ },
+ "namespace": {
+ "type": "string",
+ "title": "Namespace",
+ "description": "Created if it does not exist.",
+ "default": generated_namespace()
+ },
+ "image": {
+ "type": "string",
+ "title": "Agent image",
+ "description": "Digest-pinned image containing the buzz-acp runtime ABI, e.g. ghcr.io/block/buzz-sprig@sha256:. Tags alone are not accepted: this pod holds the agent's private key.",
+ "default": DEFAULT_IMAGE
+ },
+ "cpu_request": {
+ "type": "string", "title": "CPU request", "default": defaults.cpu_request
+ },
+ "memory_request": {
+ "type": "string", "title": "Memory request", "default": defaults.memory_request
+ },
+ "cpu_limit": {
+ "type": "string", "title": "CPU limit", "default": defaults.cpu_limit
+ },
+ "memory_limit": {
+ "type": "string", "title": "Memory limit", "default": defaults.memory_limit
+ },
+ "inactivity_seconds": {
+ "type": "number",
+ "title": "Stop after inactivity (seconds)",
+ "description": "The agent exits after this long with no work, and can be started again at any time.",
+ "default": DEFAULT_INACTIVITY_SECONDS
+ },
+ "service_account": {
+ "type": "string",
+ "title": "Service account",
+ "description": "Scheduling/RBAC identity only. No API token is mounted."
+ }
+ },
+ "required": ["namespace", "image"]
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn digest_ref() -> String {
+ format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64))
+ }
+
+ fn minimal() -> serde_json::Value {
+ serde_json::json!({"namespace": "buzz-agents-abc123", "image": digest_ref()})
+ }
+
+ #[test]
+ fn applies_spec_defaults() {
+ let c = parse(&minimal()).unwrap();
+ assert_eq!(c.resources, Resources::default());
+ assert_eq!(c.resources.cpu_request, "1");
+ assert_eq!(c.resources.memory_request, "2Gi");
+ assert_eq!(c.resources.cpu_limit, "2");
+ assert_eq!(c.resources.memory_limit, "4Gi");
+ assert_eq!(c.inactivity_seconds, Some(DEFAULT_INACTIVITY_SECONDS));
+ assert_eq!(c.context, None);
+ assert_eq!(c.service_account, None);
+ }
+
+ #[test]
+ fn all_four_resources_are_configurable() {
+ let mut cfg = minimal();
+ cfg["cpu_request"] = "500m".into();
+ cfg["memory_request"] = "1Gi".into();
+ cfg["cpu_limit"] = "4".into();
+ cfg["memory_limit"] = "8Gi".into();
+ let c = parse(&cfg).unwrap();
+ assert_eq!(
+ c.resources,
+ Resources {
+ cpu_request: "500m".into(),
+ memory_request: "1Gi".into(),
+ cpu_limit: "4".into(),
+ memory_limit: "8Gi".into(),
+ }
+ );
+ }
+
+ /// The desktop's form omits blank numeric fields; a hand-crafted payload
+ /// may send a numeric string. Both must mean the same thing.
+ #[test]
+ fn inactivity_accepts_number_string_and_omission() {
+ let mut cfg = minimal();
+ cfg["inactivity_seconds"] = serde_json::json!(300);
+ assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300));
+
+ cfg["inactivity_seconds"] = serde_json::json!("300");
+ assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300));
+
+ cfg["inactivity_seconds"] = serde_json::json!("");
+ assert_eq!(
+ parse(&cfg).unwrap().inactivity_seconds,
+ Some(DEFAULT_INACTIVITY_SECONDS)
+ );
+ }
+
+ /// Indefinite lifetime selects `OnFailure`, which is gated. Refuse rather
+ /// than silently downgrade — a downgraded agent dies on its first crash
+ /// while the user believes they asked for indefinite.
+ #[test]
+ fn refuses_indefinite_lifetime() {
+ let mut cfg = minimal();
+ cfg["inactivity_seconds"] = serde_json::json!(0);
+ let err = parse(&cfg).unwrap_err();
+ assert!(err.contains("inactivity_seconds"), "got: {err}");
+ assert!(
+ err.contains("OnFailure"),
+ "error should name the gate: {err}"
+ );
+ }
+
+ #[test]
+ fn rejects_negative_and_non_numeric_inactivity() {
+ for bad in [
+ serde_json::json!(-1),
+ serde_json::json!(1.5),
+ serde_json::json!("soon"),
+ serde_json::json!(true),
+ ] {
+ let mut cfg = minimal();
+ cfg["inactivity_seconds"] = bad.clone();
+ assert!(parse(&cfg).is_err(), "accepted {bad}");
+ }
+ }
+
+ #[test]
+ fn image_is_required_and_must_be_digest_pinned() {
+ let mut cfg = minimal();
+ cfg.as_object_mut().unwrap().remove("image");
+ assert!(parse(&cfg).unwrap_err().contains("provider_config.image"));
+
+ cfg["image"] = "ghcr.io/block/buzz-sprig:latest".into();
+ assert!(parse(&cfg).unwrap_err().contains("digest-pinned"));
+ }
+
+ #[test]
+ fn rejects_invalid_namespace_names() {
+ for bad in [
+ "",
+ "Buzz-Agents",
+ "-leading",
+ "trailing-",
+ "has_underscore",
+ &"n".repeat(64),
+ ] {
+ let mut cfg = minimal();
+ cfg["namespace"] = bad.into();
+ assert!(parse(&cfg).is_err(), "accepted namespace {bad:?}");
+ }
+ }
+
+ /// I2 corollary: there is no config path for cluster credentials, so a
+ /// caller that tries to supply one gets no effect from it. Asserting the
+ /// parsed struct has no such field is the closest a test can get to
+ /// "the type makes it impossible".
+ #[test]
+ fn credential_fields_have_no_effect() {
+ let mut cfg = minimal();
+ cfg["token"] = "hunter2".into();
+ cfg["client_key"] = "hunter2".into();
+ let c = parse(&cfg).unwrap();
+ let rendered = format!("{c:?}");
+ assert!(
+ !rendered.contains("hunter2"),
+ "config absorbed a credential: {rendered}"
+ );
+ }
+
+ #[test]
+ fn mistyped_string_fields_are_named() {
+ let mut cfg = minimal();
+ cfg["namespace"] = serde_json::json!(42);
+ assert!(parse(&cfg)
+ .unwrap_err()
+ .contains("provider_config.namespace"));
+ }
+
+ #[test]
+ fn generated_namespaces_are_fresh_and_valid() {
+ let a = generated_namespace();
+ let b = generated_namespace();
+ assert_ne!(a, b, "namespace default is not random");
+ assert!(valid_namespace(&a), "{a} is not a valid namespace");
+ assert!(a.starts_with("buzz-agents-"));
+ assert_eq!(a.len(), "buzz-agents-".len() + 6);
+ }
+
+ /// The schema's own namespace default must be a value the parser accepts —
+ /// otherwise the UI prefills a form that fails on submit.
+ #[test]
+ fn schema_default_namespace_round_trips_through_parse() {
+ let schema = config_schema();
+ let default = schema["properties"]["namespace"]["default"]
+ .as_str()
+ .unwrap();
+ let cfg = serde_json::json!({"namespace": default, "image": digest_ref()});
+ assert_eq!(parse(&cfg).unwrap().namespace, default);
+ }
+
+ /// Same guarantee for the image prefill: the schema's default must be a
+ /// value `image::parse` accepts, or the UI prefills a form that fails on
+ /// submit. Its tag+digest form normalizes to the tagless canonical form.
+ #[test]
+ fn schema_default_image_round_trips_through_parse() {
+ let schema = config_schema();
+ let default = schema["properties"]["image"]["default"].as_str().unwrap();
+ assert_eq!(default, DEFAULT_IMAGE);
+ let cfg = serde_json::json!({"namespace": "buzz-agents-abc123", "image": default});
+ let parsed = parse(&cfg).unwrap();
+ assert_eq!(
+ parsed.image.as_str(),
+ "ghcr.io/block/buzz-sprig@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76"
+ );
+ }
+
+ /// Nine fields exactly (§`provider_config` v1 fields). The cap is 20; the
+ /// count is pinned so a field added without a spec change is caught here.
+ #[test]
+ fn schema_declares_exactly_the_nine_v1_fields() {
+ let schema = config_schema();
+ let props = schema["properties"].as_object().unwrap();
+ let mut keys: Vec<&str> = props.keys().map(String::as_str).collect();
+ keys.sort();
+ assert_eq!(
+ keys,
+ [
+ "context",
+ "cpu_limit",
+ "cpu_request",
+ "image",
+ "inactivity_seconds",
+ "memory_limit",
+ "memory_request",
+ "namespace",
+ "service_account"
+ ]
+ );
+ assert_eq!(
+ schema["required"],
+ serde_json::json!(["namespace", "image"])
+ );
+ }
+
+ /// I2's key lint rejects any field whose word-split contains
+ /// secret|password|token|key|credential. A schema field tripping it would
+ /// make every deploy fail validation desktop-side (`:185-198`).
+ #[test]
+ fn no_schema_field_trips_the_i2_key_lint() {
+ const BANNED: [&str; 5] = ["secret", "password", "token", "key", "credential"];
+ let schema = config_schema();
+ for field in schema["properties"].as_object().unwrap().keys() {
+ for word in field.split(['_', '-']) {
+ assert!(
+ !BANNED.contains(&word),
+ "field {field:?} contains I2-banned word {word:?}"
+ );
+ }
+ }
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/env.rs b/crates/buzz-backend-kubernetes/src/env.rs
new file mode 100644
index 0000000000..badff621e8
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/env.rs
@@ -0,0 +1,732 @@
+//! Building the pod environment (spec §Launch data, §Entrypoint mapping table).
+//!
+//! The three tiers are resolved *here*, before serialization, because a
+//! Kubernetes Secret's `data` is a flat map with no precedence of its own: if
+//! two tiers supplied the same key, whichever entry landed in the map would
+//! win silently. Resolving in-provider makes later-wins explicit and testable.
+
+use crate::wire::{AgentPayload, LaunchBlock};
+use std::collections::BTreeMap;
+
+/// Keys the authoritative tier owns.
+///
+/// Load-bearing, not documentation: tier 3 *clears* every key on this list
+/// before writing its own values, so a key the authoritative tier has no value
+/// for is **removed** rather than left holding a lower-tier value. Plain
+/// overwrite is not enough — most of these are written conditionally
+/// (`BUZZ_ACP_AGENT_ARGS` only when `launch.args` is non-empty,
+/// `BUZZ_ACP_RESPOND_TO` only when set), and without the clear, a lower tier
+/// could supply the value for exactly the cases the authoritative tier stays
+/// silent on. Clearing is also what the local spawn does: the desktop strips
+/// reserved keys from user env before the authoritative layer is written
+/// (`env_vars.rs:54-57`), so absent-means-absent in both paths.
+const AUTHORITATIVE_KEYS: &[&str] = &[
+ "BUZZ_RELAY_URL",
+ "BUZZ_PRIVATE_KEY",
+ "NOSTR_PRIVATE_KEY",
+ "BUZZ_AUTH_TAG",
+ "BUZZ_ACP_AGENT_OWNER",
+ "BUZZ_ACP_AGENT_COMMAND",
+ "BUZZ_ACP_AGENT_ARGS",
+ "BUZZ_ACP_RESPOND_TO",
+ "BUZZ_ACP_RESPOND_TO_ALLOWLIST",
+ "BUZZ_ACP_MCP_COMMAND",
+ "BUZZ_ACP_EXIT_AFTER_INACTIVITY",
+ START_NONCE_KEY,
+];
+
+/// The attempt's generation, as the harness sees it. Also the Secret's name
+/// suffix — one generation, one identity — so the reconciler restamps this on
+/// every create attempt rather than letting the caller's value persist across
+/// a retry.
+pub const START_NONCE_KEY: &str = "BUZZ_MANAGED_AGENT_START_NONCE";
+
+/// Presence is the only remote liveness signal (I3), so a launch that
+/// suppresses it is non-conforming (L1 item 2) — and unlike a reserved-key
+/// collision, there is no "authoritative value" to overwrite it with. Refuse.
+const FORBIDDEN_KEY: &str = "BUZZ_ACP_NO_PRESENCE";
+
+/// Kubernetes' own cap on the summed value bytes of a Secret
+/// (`MaxSecretSize`, `pkg/apis/core/types.go`). Enforced here so an oversized
+/// env surfaces as a named provider error rather than an apiserver rejection
+/// partway through a deploy.
+const MAX_SECRET_BYTES: usize = 1024 * 1024;
+
+/// A POSIX-shaped env var name: `[A-Za-z_][A-Za-z0-9_]*`.
+///
+/// Kubernetes validates Secret *keys* as `IsConfigMapKey`
+/// (`[-._a-zA-Z0-9]+`), which is looser — `foo.bar` is a legal Secret key.
+/// What the kubelet then does with such a key **changed between versions**:
+/// through 1.29 it filtered invalid env names out of `envFrom` and emitted an
+/// `InvalidEnvironmentVariableNames` warning event
+/// (`pkg/kubelet/kubelet_pods.go:646,654` at v1.29.0); from 1.30 that filter
+/// is gone (KEP-4369) and the key is injected verbatim. The same manifest
+/// would silently drop a variable on one cluster and set it on another, so we
+/// fail closed on the provider side and get one deterministic behavior.
+fn is_posix_env_key(key: &str) -> bool {
+ let mut chars = key.chars();
+ match chars.next() {
+ Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
+ _ => return false,
+ }
+ chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
+}
+
+/// An identity component (L1 item 1) is present only if it is nonempty after
+/// trimming — and the **trimmed form is what gets stored**. The validator and
+/// the writer must never disagree about the value: a guard that accepts
+/// `" wss://relay "` and then writes it with the padding intact has only
+/// moved the failure from a loud refusal to a connect error in the harness.
+fn identity_component(value: &str) -> Option<&str> {
+ let trimmed = value.trim();
+ (!trimmed.is_empty()).then_some(trimmed)
+}
+
+/// The harness's `allowlist` gate mode, spelled as the desktop serializes
+/// `RespondTo` (kebab-case) and as `buzz-acp`'s CLI parses it.
+const RESPOND_TO_ALLOWLIST: &str = "allowlist";
+
+/// Every gate mode `buzz-acp` accepts, spelled as its `clap::ValueEnum` parses
+/// them (`config.rs:95-101`, kebab-case via `RespondTo`'s `Display`).
+///
+/// Deliberately the **harness's** four and not the desktop's three: the desktop
+/// rejects `nobody` on purpose (`managed_agents/types.rs:871-880`), but the
+/// harness starts fine with it. This guard exists to cover non-desktop callers,
+/// so inheriting a desktop-only narrowing would refuse a launch that works.
+const RESPOND_TO_MODES: [&str; 4] = ["owner-only", RESPOND_TO_ALLOWLIST, "anyone", "nobody"];
+
+/// Refuse a respond-to gate the harness will reject at config parse.
+///
+/// The local spawn path re-validates this before spawning — "doing it here
+/// means we never spawn a doomed process" (`runtime.rs:378`) — but the deploy
+/// path projects the record's fields straight through. Without this, a gate
+/// the harness refuses becomes a pod that exits 1 at startup; `restartPolicy:
+/// Never` turns that into `Terminated` → `Delete` → recreate, and each cycle
+/// leaves a Secret the in-call path never reaps (only a later deploy's orphan
+/// sweep does, at `ORPHAN_SECRET_MIN_AGE_SECS`). The user-visible ending is
+/// "startup not confirmed", indistinguishable from a slow cluster.
+///
+/// Mirrors `buzz-acp`'s own rules exactly (`config.rs:95-101,996-1004,629-641`),
+/// deliberately including their asymmetry: the allowlist is validated **only**
+/// in allowlist mode, and merely warned about otherwise. Validating it in
+/// every mode would refuse a deploy whose identical local spawn succeeds —
+/// and a stale list is already harmless here, since
+/// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` is an authoritative key that tier 3 clears.
+fn validate_respond_to_gate(respond_to: &str, allowlist: Option<&[String]>) -> Result<(), String> {
+ // Exact, untrimmed: `clap` does not trim, so `" allowlist "` is `rc=2` at
+ // the harness — a parse failure even earlier than the config errors below.
+ if !RESPOND_TO_MODES.contains(&respond_to) {
+ return Err(format!(
+ "deploy refused: respond_to {respond_to:?} is not a mode the \
+ harness accepts (expected one of {}) — the pod would fail to \
+ parse its arguments, be replaced, and leave a Secret behind on \
+ every attempt",
+ RESPOND_TO_MODES.join(", ")
+ ));
+ }
+ if respond_to != RESPOND_TO_ALLOWLIST {
+ return Ok(());
+ }
+ let entries = allowlist.unwrap_or_default();
+ if entries.is_empty() {
+ return Err(format!(
+ "deploy refused: respond_to is {RESPOND_TO_ALLOWLIST:?} but the \
+ allowlist is empty — the harness refuses this at startup, so the \
+ pod would fail, be replaced, and leave a Secret behind on every \
+ attempt"
+ ));
+ }
+ for entry in entries {
+ let trimmed = entry.trim();
+ if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
+ return Err(format!(
+ "deploy refused: invalid pubkey in respond_to_allowlist: \
+ {entry:?} (must be exactly 64 hex characters)"
+ ));
+ }
+ }
+ Ok(())
+}
+
+/// Inputs the provider itself supplies to the authoritative tier.
+pub struct AuthoritativeInputs<'a> {
+ /// The attempt's generation token — also the Secret's name suffix, so the
+ /// lifecycle correlator and the Secret generation are one identity.
+ pub generation: &'a str,
+ /// Resolved from `provider_config.inactivity_seconds`; `None` when the
+ /// indefinite opt-in was chosen (which this version refuses elsewhere).
+ pub inactivity_seconds: Option,
+}
+
+/// Resolve the full pod environment.
+///
+/// Order is the spec's, and the function body is deliberately three writes in
+/// that order — tier 1, tier 2, tier 3 — so "later wins" is visible rather
+/// than argued.
+pub fn build_env(
+ agent: &AgentPayload,
+ auth: AuthoritativeInputs<'_>,
+) -> Result, String> {
+ let default_launch = LaunchBlock::default();
+ let launch = agent.launch.as_ref().unwrap_or(&default_launch);
+
+ let mut env: BTreeMap = BTreeMap::new();
+
+ // Tier 1 — overridable behavior defaults.
+ env.extend(launch.policy_env.clone());
+
+ // Tier 2 — user/layered env. The descriptor already merged
+ // global < persona < agent, so `agent.env_vars` is NOT re-merged on top
+ // (§Launch data tier 2) — doing so would resurrect a layer the desktop
+ // already resolved. When the desktop predates the `launch` block we fall
+ // back to the legacy field, which is the only case it is the truth.
+ if agent.launch.is_some() {
+ env.extend(launch.env.clone());
+ } else {
+ env.extend(agent.env_vars.clone());
+ }
+
+ // Validate what the lower tiers contributed, before the authoritative
+ // tier overwrites any of it. A reserved-key collision is NOT fatal: the
+ // spec's precedence is later-wins, so tier 3 simply overwrites it, which
+ // is exactly what a local spawn does. Only a key that has no
+ // authoritative counterpart to overwrite it — presence suppression — is
+ // a refusal.
+ for key in env.keys() {
+ if !is_posix_env_key(key) {
+ return Err(format!(
+ "env key {key:?} is not a POSIX environment variable name \
+ ([A-Za-z_][A-Za-z0-9_]*); Kubernetes would treat it \
+ inconsistently across cluster versions"
+ ));
+ }
+ if key.eq_ignore_ascii_case(FORBIDDEN_KEY) {
+ return Err(format!(
+ "{FORBIDDEN_KEY} must not be set on a remote agent: presence \
+ is the only signal that a remote agent is alive"
+ ));
+ }
+ }
+
+ // Tier 3 — authoritative. Every key it owns is cleared first, then the
+ // values it has are written, so it wins at a key whether or not it has a
+ // value there (see [`AUTHORITATIVE_KEYS`]).
+ for key in AUTHORITATIVE_KEYS {
+ env.remove(*key);
+ }
+ // Identity comes from top-level payload fields, never from `env_vars`
+ // (§Reserved-key rule). All three components must be nonempty: an agent
+ // that cannot reach a relay is the identityless launch L1 item 1 exists to
+ // prevent, and a blank field would otherwise sail through into the Secret
+ // and produce a pod that starts, fails to connect, and looks like a
+ // network problem.
+ let Some(relay_url) = identity_component(&agent.relay_url) else {
+ return Err("deploy refused: relay_url is empty — the agent would have \
+ no relay to connect to"
+ .to_string());
+ };
+ env.insert("BUZZ_RELAY_URL".into(), relay_url.to_string());
+ env.insert("BUZZ_PRIVATE_KEY".into(), agent.private_key_nsec.clone());
+ // The git credential/signing helpers read NOSTR_PRIVATE_KEY.
+ env.insert("NOSTR_PRIVATE_KEY".into(), agent.private_key_nsec.clone());
+
+ // Owner: at least one of these must resolve, or the harness cannot match
+ // `!shutdown` and §Stop describes a mechanism that does not work.
+ let auth_tag = agent.auth_tag.as_deref().and_then(identity_component);
+ let owner = launch.owner_pubkey.as_deref().and_then(identity_component);
+ match (auth_tag, owner) {
+ (None, None) => {
+ return Err("deploy refused: neither auth_tag nor launch.owner_pubkey \
+ resolved — without an owner the agent cannot honor \
+ !shutdown"
+ .to_string())
+ }
+ (tag, own) => {
+ if let Some(t) = tag {
+ env.insert("BUZZ_AUTH_TAG".into(), t.to_string());
+ }
+ if let Some(o) = own {
+ env.insert("BUZZ_ACP_AGENT_OWNER".into(), o.to_string());
+ }
+ }
+ }
+
+ // The harness and MCP binaries are resolved against the *image's* PATH.
+ // A host path forwarded from the desktop is guaranteed absent in the
+ // container (§Launch data, host-resolved values).
+ if let Some(command) = launch.command.as_deref().filter(|c| !c.is_empty()) {
+ env.insert("BUZZ_ACP_AGENT_COMMAND".into(), command.to_string());
+ }
+ if !launch.args.is_empty() {
+ // Comma-joined because that is what the harness's CLI parser decodes,
+ // and what the desktop's local spawn does. An argument containing a
+ // comma is unrepresentable in both paths; inventing an escaping
+ // scheme here would produce args the harness cannot decode.
+ env.insert("BUZZ_ACP_AGENT_ARGS".into(), launch.args.join(","));
+ }
+ env.insert("BUZZ_ACP_MCP_COMMAND".into(), "buzz-dev-mcp".into());
+
+ if let Some(respond_to) = agent.respond_to.as_deref().filter(|s| !s.is_empty()) {
+ validate_respond_to_gate(respond_to, agent.respond_to_allowlist.as_deref())?;
+ env.insert("BUZZ_ACP_RESPOND_TO".into(), respond_to.to_string());
+ }
+ if let Some(list) = agent
+ .respond_to_allowlist
+ .as_ref()
+ .filter(|l| !l.is_empty())
+ {
+ env.insert("BUZZ_ACP_RESPOND_TO_ALLOWLIST".into(), list.join(","));
+ }
+
+ if let Some(secs) = auth.inactivity_seconds {
+ env.insert("BUZZ_ACP_EXIT_AFTER_INACTIVITY".into(), secs.to_string());
+ }
+ // The generation token doubles as the lifecycle-frame correlator, so pod
+ // logs and observer frames share one identity (§K8s Secrets).
+ env.insert(START_NONCE_KEY.into(), auth.generation.to_string());
+
+ let total: usize = env.values().map(String::len).sum();
+ if total > MAX_SECRET_BYTES {
+ return Err(format!(
+ "agent environment is {total} bytes; Kubernetes caps Secret data \
+ at {MAX_SECRET_BYTES}"
+ ));
+ }
+
+ Ok(env)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn payload_json(extra_agent: serde_json::Value) -> AgentPayload {
+ let mut agent = serde_json::json!({
+ "name": "a",
+ "relay_url": "wss://relay.example",
+ "private_key_nsec": "nsec1example",
+ "auth_tag": "tag-1",
+ });
+ let (serde_json::Value::Object(base), serde_json::Value::Object(extra)) =
+ (&mut agent, extra_agent)
+ else {
+ panic!("expected objects")
+ };
+ base.extend(extra);
+ serde_json::from_value(agent).unwrap()
+ }
+
+ fn build(agent: &AgentPayload) -> Result, String> {
+ build_env(
+ agent,
+ AuthoritativeInputs {
+ generation: "gen0001",
+ inactivity_seconds: Some(7200),
+ },
+ )
+ }
+
+ #[test]
+ fn identity_comes_from_top_level_fields() {
+ let env = build(&payload_json(serde_json::json!({}))).unwrap();
+ assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
+ assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example");
+ assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example");
+ assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
+ }
+
+ /// Wren's amendment, and the spec's later-wins rule: a lower tier that
+ /// spoofs an authoritative key is *overwritten*, not refused. Refusing
+ /// would diverge from the local spawn, where the same env is written
+ /// before the authoritative layer and simply loses.
+ #[test]
+ fn lower_tiers_cannot_spoof_authoritative_values() {
+ let agent = payload_json(serde_json::json!({
+ "launch": {
+ "command": "goose",
+ "policy_env": {
+ "BUZZ_PRIVATE_KEY": "nsec1attacker",
+ "BUZZ_MANAGED_AGENT_START_NONCE": "forged",
+ },
+ "env": {
+ "BUZZ_RELAY_URL": "wss://attacker.example",
+ "NOSTR_PRIVATE_KEY": "nsec1attacker",
+ "BUZZ_AUTH_TAG": "forged-tag",
+ "BUZZ_ACP_AGENT_OWNER": "cafe",
+ "BUZZ_ACP_AGENT_COMMAND": "/bin/sh",
+ "BUZZ_ACP_MCP_COMMAND": "/bin/sh",
+ "BUZZ_ACP_EXIT_AFTER_INACTIVITY": "0",
+ },
+ "owner_pubkey": "beef"
+ }
+ }));
+ let env = build(&agent).unwrap();
+ assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example");
+ assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example");
+ assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
+ assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
+ assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beef");
+ assert_eq!(env["BUZZ_ACP_AGENT_COMMAND"], "goose");
+ assert_eq!(env["BUZZ_ACP_MCP_COMMAND"], "buzz-dev-mcp");
+ assert_eq!(env["BUZZ_ACP_EXIT_AFTER_INACTIVITY"], "7200");
+ assert_eq!(env["BUZZ_MANAGED_AGENT_START_NONCE"], "gen0001");
+ }
+
+ /// Tier 1 is *overridable* — user env beats policy defaults, matching the
+ /// local spawn, where the user layer is written after them. Getting this
+ /// backwards would make remote agents ignore overrides local agents honor.
+ #[test]
+ fn user_env_overrides_policy_defaults() {
+ let agent = payload_json(serde_json::json!({
+ "launch": {
+ "policy_env": {"GOOSE_MODE": "auto", "BUZZ_ACP_MODEL": "sonnet"},
+ "env": {"GOOSE_MODE": "chat"},
+ "owner_pubkey": "beef"
+ }
+ }));
+ let env = build(&agent).unwrap();
+ assert_eq!(env["GOOSE_MODE"], "chat");
+ assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet");
+ }
+
+ /// `launch.env` already contains the merged user env, so re-merging the
+ /// legacy field would undo a layering the desktop already resolved.
+ #[test]
+ fn legacy_env_vars_are_not_remerged_when_launch_present() {
+ let agent = payload_json(serde_json::json!({
+ "env_vars": {"STALE": "yes", "SHARED": "legacy"},
+ "launch": {"env": {"SHARED": "resolved"}, "owner_pubkey": "beef"}
+ }));
+ let env = build(&agent).unwrap();
+ assert_eq!(env["SHARED"], "resolved");
+ assert!(!env.contains_key("STALE"), "legacy env_vars re-merged");
+ }
+
+ /// ...but a desktop predating the `launch` block has nothing else to
+ /// offer, so the legacy field is the truth in exactly that case.
+ #[test]
+ fn legacy_env_vars_used_when_launch_absent() {
+ let agent = payload_json(serde_json::json!({"env_vars": {"API": "v"}}));
+ let env = build(&agent).unwrap();
+ assert_eq!(env["API"], "v");
+ }
+
+ #[test]
+ fn refuses_when_no_owner_resolves() {
+ let agent = payload_json(serde_json::json!({"auth_tag": null}));
+ let err = build(&agent).unwrap_err();
+ assert!(err.contains("!shutdown"), "unhelpful error: {err}");
+ }
+
+ /// An empty string is not an owner. Without this the refusal is
+ /// bypassable by a blank field and the pod launches unable to be stopped.
+ #[test]
+ fn empty_owner_fields_count_as_absent() {
+ for blank in ["", " "] {
+ let agent = payload_json(serde_json::json!({
+ "auth_tag": blank,
+ "launch": {"owner_pubkey": blank}
+ }));
+ assert!(
+ build(&agent).is_err(),
+ "whitespace resolved as an owner: {blank:?}"
+ );
+ }
+ }
+
+ /// The other half of every identity guard: what is *stored*. A validator
+ /// that trims and a writer that doesn't disagree about the value, and the
+ /// padding reaches the harness inside the Secret. Assert on the stored
+ /// string — asserting only that the deploy was accepted passes either way.
+ #[test]
+ fn identity_components_are_stored_trimmed() {
+ let agent = payload_json(serde_json::json!({
+ "relay_url": " wss://relay.example ",
+ "auth_tag": " tag-1 ",
+ "launch": {"owner_pubkey": " beefcafe "}
+ }));
+ let env = build(&agent).unwrap();
+ assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
+ assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
+ assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe");
+ }
+
+ /// L1 item 1's third identity component. The nsec arm is enforced in
+ /// `naming.rs` and the owner arm above; without this one an agent
+ /// deploys with nothing to connect to — a pod that starts, fails at the
+ /// relay, and reads as a network fault rather than a refused launch.
+ #[test]
+ fn refuses_empty_relay_url() {
+ for blank in ["", " "] {
+ let agent = payload_json(serde_json::json!({"relay_url": blank}));
+ let err = build(&agent).unwrap_err();
+ assert!(err.contains("relay_url"), "unhelpful error: {err}");
+ }
+ }
+
+ #[test]
+ fn owner_pubkey_alone_is_sufficient() {
+ let agent = payload_json(serde_json::json!({
+ "auth_tag": null,
+ "launch": {"owner_pubkey": "beefcafe"}
+ }));
+ let env = build(&agent).unwrap();
+ assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe");
+ assert!(!env.contains_key("BUZZ_AUTH_TAG"));
+ }
+
+ #[test]
+ fn refuses_presence_suppression() {
+ let agent = payload_json(serde_json::json!({
+ "launch": {"env": {"BUZZ_ACP_NO_PRESENCE": "1"}, "owner_pubkey": "beef"}
+ }));
+ let err = build(&agent).unwrap_err();
+ assert!(err.contains("BUZZ_ACP_NO_PRESENCE"), "got: {err}");
+ }
+
+ /// `foo.bar` is a legal Secret key but not a legal env name: pre-1.30
+ /// kubelets drop it, 1.30+ inject it. Refuse rather than behave
+ /// differently depending on the cluster.
+ #[test]
+ fn refuses_non_posix_env_keys() {
+ for bad in ["foo.bar", "foo-bar", "1LEADING", "", "has space"] {
+ let agent = payload_json(serde_json::json!({
+ "launch": {"env": {bad: "v"}, "owner_pubkey": "beef"}
+ }));
+ assert!(build(&agent).is_err(), "accepted non-POSIX key {bad:?}");
+ }
+ }
+
+ #[test]
+ fn args_are_comma_joined_and_omitted_when_empty() {
+ let agent = payload_json(serde_json::json!({
+ "launch": {"command": "goose", "args": ["run", "--no-session"], "owner_pubkey": "b"}
+ }));
+ let env = build(&agent).unwrap();
+ assert_eq!(env["BUZZ_ACP_AGENT_ARGS"], "run,--no-session");
+
+ let agent = payload_json(serde_json::json!({
+ "launch": {"command": "goose", "args": [], "owner_pubkey": "b"}
+ }));
+ assert!(!build(&agent).unwrap().contains_key("BUZZ_ACP_AGENT_ARGS"));
+ }
+
+ /// The top-level `model`/`provider` fields are display inputs; their
+ /// environment consequence is per-runtime and arrives already resolved
+ /// inside `launch`. A provider-side mapping is wrong for three of the
+ /// four built-in runtimes.
+ #[test]
+ fn provider_never_maps_model_or_provider_itself() {
+ let agent = payload_json(serde_json::json!({
+ "model": "claude-opus", "provider": "anthropic",
+ "launch": {"owner_pubkey": "beef"}
+ }));
+ let env = build(&agent).unwrap();
+ for key in [
+ "BUZZ_AGENT_PROVIDER",
+ "BUZZ_AGENT_MODEL",
+ "GOOSE_PROVIDER",
+ "GOOSE_MODEL",
+ ] {
+ assert!(!env.contains_key(key), "provider mapped {key} itself");
+ }
+ }
+
+ /// `turn_timeout_seconds` is deprecated and ignored upstream; the local
+ /// spawn does not emit it either.
+ #[test]
+ fn turn_timeout_is_not_mapped() {
+ let agent = payload_json(serde_json::json!({
+ "turn_timeout_seconds": 30, "launch": {"owner_pubkey": "b"}
+ }));
+ let env = build(&agent).unwrap();
+ assert!(!env.keys().any(|k| k.contains("TURN_TIMEOUT")));
+ }
+
+ #[test]
+ fn inactivity_omitted_when_unset() {
+ let agent = payload_json(serde_json::json!({"launch": {"owner_pubkey": "b"}}));
+ let env = build_env(
+ &agent,
+ AuthoritativeInputs {
+ generation: "g",
+ inactivity_seconds: None,
+ },
+ )
+ .unwrap();
+ assert!(!env.contains_key("BUZZ_ACP_EXIT_AFTER_INACTIVITY"));
+ }
+
+ /// Structural guard over the whole authoritative list at once: whatever
+ /// the lower tiers contain, no authoritative key holds a lower-tier value
+ /// — including the conditionally-written ones the authoritative tier has
+ /// nothing to say about, which must be **absent** rather than spoofed.
+ /// This test caught exactly that: `BUZZ_ACP_AGENT_ARGS` is only written
+ /// when `launch.args` is non-empty, so plain later-wins overwrite left the
+ /// spoofed value in place.
+ #[test]
+ fn no_authoritative_key_retains_a_lower_tier_value() {
+ let spoofed: serde_json::Map = AUTHORITATIVE_KEYS
+ .iter()
+ .map(|k| ((*k).to_string(), serde_json::json!("SPOOFED")))
+ .collect();
+ // Split across both lower tiers: policy_env and env are separate
+ // insertion points, and a fix that only cleared one would pass a
+ // single-tier test.
+ let agent = payload_json(serde_json::json!({
+ "launch": {
+ "command": "goose",
+ "policy_env": spoofed.clone(),
+ "env": spoofed,
+ "owner_pubkey": "beef"
+ }
+ }));
+ let env = build(&agent).unwrap();
+ for key in AUTHORITATIVE_KEYS {
+ assert_ne!(
+ env.get(*key).map(String::as_str),
+ Some("SPOOFED"),
+ "{key} kept its lower-tier value"
+ );
+ }
+ // The keys the authoritative tier had no value for are gone, not
+ // merely different.
+ for absent in [
+ "BUZZ_ACP_AGENT_ARGS",
+ "BUZZ_ACP_RESPOND_TO",
+ "BUZZ_ACP_RESPOND_TO_ALLOWLIST",
+ ] {
+ assert!(!env.contains_key(absent), "{absent} survived the clear");
+ }
+ }
+
+ /// A 64-hex pubkey, the only allowlist entry shape the harness accepts.
+ fn pubkey(fill: char) -> String {
+ std::iter::repeat_n(fill, 64).collect()
+ }
+
+ /// The gate the harness refuses first (`config.rs:996-1004`). Refusing it
+ /// here is the difference between one error message and an unbounded
+ /// fail-replace loop that leaves a Secret per attempt.
+ #[test]
+ fn allowlist_mode_with_an_empty_list_is_refused() {
+ for empty in [serde_json::json!([]), serde_json::Value::Null] {
+ let agent = payload_json(serde_json::json!({
+ "respond_to": "allowlist",
+ "respond_to_allowlist": empty,
+ }));
+ let err = build(&agent).unwrap_err();
+ assert!(
+ err.contains("the allowlist is empty"),
+ "unexpected error: {err}"
+ );
+ }
+ }
+
+ /// `config.rs:629-641` — each entry must be exactly 64 hex characters.
+ /// The rejects are the distinct ways to miss that: too short, right length
+ /// but not hex, empty, and one character short of valid.
+ #[test]
+ fn an_allowlist_entry_that_is_not_64_hex_is_refused() {
+ for bad in ["abc1234", &"z".repeat(64), "", &pubkey('a')[..63]] {
+ let agent = payload_json(serde_json::json!({
+ "respond_to": "allowlist",
+ "respond_to_allowlist": [pubkey('a'), bad],
+ }));
+ let err = build(&agent).unwrap_err();
+ assert!(
+ err.contains("must be exactly 64 hex characters"),
+ "{bad:?} was accepted; error was: {err}"
+ );
+ }
+ }
+
+ /// The positive control: the guard refuses bad gates, not every gate.
+ /// Without this, a validator that refused unconditionally would pass both
+ /// tests above.
+ #[test]
+ fn a_valid_allowlist_gate_is_accepted_and_comma_joined() {
+ let agent = payload_json(serde_json::json!({
+ "respond_to": "allowlist",
+ "respond_to_allowlist": [pubkey('a'), pubkey('b')],
+ }));
+ let env = build(&agent).unwrap();
+ assert_eq!(env["BUZZ_ACP_RESPOND_TO"], "allowlist");
+ assert_eq!(
+ env["BUZZ_ACP_RESPOND_TO_ALLOWLIST"],
+ format!("{},{}", pubkey('a'), pubkey('b'))
+ );
+ }
+
+ /// The harness validates the allowlist **only** in allowlist mode and
+ /// merely warns otherwise (`config.rs:1005-1010`). A stricter provider
+ /// would refuse a deploy whose identical local spawn succeeds, so this
+ /// pins the asymmetry rather than leaving it to look like an oversight.
+ #[test]
+ fn a_junk_allowlist_is_tolerated_outside_allowlist_mode() {
+ for mode in ["owner-only", "anyone"] {
+ let agent = payload_json(serde_json::json!({
+ "respond_to": mode,
+ "respond_to_allowlist": ["not-a-pubkey"],
+ }));
+ let env = build(&agent)
+ .unwrap_or_else(|e| panic!("{mode} with a stale list must deploy: {e}"));
+ assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode);
+ }
+ }
+
+ /// `respond_to` is an opaque `String` on the wire but a `clap::ValueEnum`
+ /// at the harness, so an unrecognized mode dies at `rc=2` — before config
+ /// parsing runs at all, earlier than either refusal above. Measured
+ /// against the built binary: `invalid value 'npub1abc' for '--respond-to'`.
+ /// This is the shape our own fixture carried until it was corrected.
+ #[test]
+ fn a_mode_the_harness_cannot_parse_is_refused() {
+ for bad in ["npub1abc", "OWNER-ONLY", "owner_only", "allowlistt", "x"] {
+ let agent = payload_json(serde_json::json!({ "respond_to": bad }));
+ let err = build(&agent).unwrap_err();
+ assert!(
+ err.contains("is not a mode the harness accepts"),
+ "{bad:?} was accepted; error was: {err}"
+ );
+ }
+ }
+
+ /// `clap` does not trim its value-enum input, so a padded mode is `rc=2`
+ /// even though the same string trimmed is valid. Measured: `invalid value
+ /// ' allowlist ' for '--respond-to'`. Trimming here would accept a deploy
+ /// the harness refuses — the exact direction this guard exists to prevent.
+ #[test]
+ fn a_padded_mode_is_refused_because_clap_does_not_trim() {
+ for padded in [" allowlist ", "allowlist ", " owner-only", "\tnobody"] {
+ let agent = payload_json(serde_json::json!({
+ "respond_to": padded,
+ "respond_to_allowlist": [pubkey('a')],
+ }));
+ let err = build(&agent).unwrap_err();
+ assert!(
+ err.contains("is not a mode the harness accepts"),
+ "{padded:?} was accepted; error was: {err}"
+ );
+ }
+ }
+
+ /// Positive control for the mode check, and the reason it validates the
+ /// harness's four rather than the desktop's three: `nobody` is rejected by
+ /// `parse_wire` on purpose (`managed_agents/types.rs:871-880`) but starts
+ /// fine at the harness. A guard mirroring the desktop enum would refuse a
+ /// working launch from a non-desktop caller — the callers this guard is
+ /// for. Without this test, refusing `nobody` would pass everything above.
+ #[test]
+ fn every_mode_the_harness_accepts_is_deployable() {
+ for mode in ["owner-only", "allowlist", "anyone", "nobody"] {
+ let agent = payload_json(serde_json::json!({
+ "respond_to": mode,
+ "respond_to_allowlist": [pubkey('a')],
+ }));
+ let env = build(&agent)
+ .unwrap_or_else(|e| panic!("{mode} is valid at the harness but was refused: {e}"));
+ assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode);
+ }
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/gc.rs b/crates/buzz-backend-kubernetes/src/gc.rs
new file mode 100644
index 0000000000..2e18825aa4
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/gc.rs
@@ -0,0 +1,368 @@
+//! Preflight garbage collection (spec §K8s GC, `docs/remote-agents.md:1282-1335`).
+//!
+//! GC runs on every deploy, after identity derivation and before the state
+//! transition. It deletes terminated pods (and their referenced Secrets) and
+//! age-eligible orphan Secrets — every one of which must pass the full-pubkey
+//! annotation check *and* carry the management marker. An unmarked object is
+//! never GC'd regardless of its labels.
+//!
+//! The decision layer here is pure. The effectful caller supplies the observed
+//! objects and the apiserver's clock; this module decides what may be deleted.
+
+use crate::naming::AgentIdentity;
+use crate::observe::{referenced_secret, secret_is_ours};
+use chrono::{DateTime, Utc};
+use k8s_openapi::api::core::v1::{Pod, Secret};
+
+/// The deploy operation deadline (spec §Deploy: `timeout: 600s`).
+pub const OPERATION_DEADLINE_SECS: i64 = 600;
+
+/// An unreferenced Secret is GC-eligible only once it is older than **twice**
+/// the deploy deadline. Rationale: Secret-create → pod-create is not atomic
+/// against an independent GC pass, so without the gate a concurrent attempt's
+/// preflight GC can delete a Secret whose pod has not been created yet and
+/// strand that deploy. The age bound makes "unreferenced" mean "provably
+/// abandoned" — any attempt that could still reference it has exceeded its own
+/// deadline (`:1301-1319`).
+pub const ORPHAN_SECRET_MIN_AGE_SECS: i64 = 2 * OPERATION_DEADLINE_SECS;
+
+/// What a GC pass decided to delete. Names only: the caller re-reads each
+/// object's own fence at delete time.
+#[derive(Debug, Default, PartialEq, Eq)]
+pub struct GcPlan {
+ /// Terminated, verified, marker-bearing pods.
+ pub pods: Vec,
+ /// Age-eligible, verified, marker-bearing orphan Secrets.
+ pub secrets: Vec,
+}
+
+/// Plan a GC pass.
+///
+/// `now` is the apiserver's clock — the HTTP `Date` header from the very list
+/// call that produced `secrets`. `None` means the header was absent or
+/// unparseable, in which case **orphan-Secret GC is skipped entirely** rather
+/// than falling back to local time: this provider runs on a user's desktop,
+/// and a local clock fast by more than the margin does not race — it
+/// deterministically computes every in-flight Secret as expired, on every
+/// pass, reopening exactly the interleaving the gate exists to close
+/// (`:1321-1335`). A deferred cleanup is free; a wrong deletion is not.
+///
+/// Terminated-pod GC does not use the clock and is unaffected.
+pub fn plan(
+ identity: &AgentIdentity,
+ pods: &[Pod],
+ secrets: &[Secret],
+ terminated: impl Fn(&Pod) -> bool,
+ now: Option>,
+) -> GcPlan {
+ // Only pods that pass the full fence participate — in either direction.
+ // An unverified pod is neither deleted nor allowed to protect a Secret:
+ // it cannot be ours, so its `envFrom` cannot reference our generation.
+ let ours: Vec<&Pod> = pods
+ .iter()
+ .filter(|p| {
+ crate::observe::verify(p, identity, crate::classify::Startup::Started).is_some()
+ })
+ .collect();
+
+ let doomed_pods: Vec<&&Pod> = ours.iter().filter(|p| terminated(p)).collect();
+
+ // A Secret referenced by ANY existing pod is protected — deliberately
+ // including not-yet-started pods, whose `envFrom` is exactly as
+ // load-bearing as a running pod's (`:1262-1264`). Pods being GC'd in this
+ // same pass are excluded, so their Secrets go with them.
+ let doomed_names: Vec<&str> = doomed_pods
+ .iter()
+ .filter_map(|p| p.metadata.name.as_deref())
+ .collect();
+ let protected: Vec = ours
+ .iter()
+ .filter(|p| !doomed_names.contains(&p.metadata.name.as_deref().unwrap_or_default()))
+ .filter_map(|p| referenced_secret(p))
+ .collect();
+
+ let mut plan = GcPlan {
+ pods: doomed_names.iter().map(|n| n.to_string()).collect(),
+ secrets: doomed_pods
+ .iter()
+ .filter_map(|p| referenced_secret(p))
+ .collect(),
+ };
+
+ // Orphan sweep: only with a server clock.
+ if let Some(now) = now {
+ for secret in secrets {
+ if !secret_is_ours(secret, identity) {
+ continue;
+ }
+ let Some(name) = secret.metadata.name.as_deref() else {
+ continue;
+ };
+ if protected.contains(&name.to_string()) || plan.secrets.iter().any(|s| s == name) {
+ continue;
+ }
+ let Some(created) = secret.metadata.creation_timestamp.as_ref() else {
+ // No server-assigned timestamp means no age proof. Skip.
+ continue;
+ };
+ if (now - created.0).num_seconds() >= ORPHAN_SECRET_MIN_AGE_SECS {
+ plan.secrets.push(name.to_string());
+ }
+ }
+ }
+
+ plan
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::naming::{ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY};
+ use k8s_openapi::api::core::v1::{Container, EnvFromSource, PodSpec, SecretEnvSource};
+ use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time};
+ use std::collections::BTreeMap;
+
+ fn identity() -> AgentIdentity {
+ use nostr::nips::nip19::ToBech32;
+ let keys = nostr::Keys::generate();
+ AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
+ }
+
+ fn pod_named(id: &AgentIdentity, name: &str, secret: Option<&str>) -> Pod {
+ Pod {
+ metadata: ObjectMeta {
+ name: Some(name.into()),
+ uid: Some(format!("uid-{name}")),
+ resource_version: Some("1".into()),
+ labels: Some(id.labels()),
+ annotations: Some(
+ [(
+ ANNOTATION_PUBKEY_FULL.to_string(),
+ id.pubkey_hex().to_string(),
+ )]
+ .into_iter()
+ .collect::>(),
+ ),
+ ..Default::default()
+ },
+ spec: secret.map(|s| PodSpec {
+ containers: vec![Container {
+ name: "agent".into(),
+ env_from: Some(vec![EnvFromSource {
+ secret_ref: Some(SecretEnvSource {
+ name: s.into(),
+ optional: Some(false),
+ }),
+ ..Default::default()
+ }]),
+ ..Default::default()
+ }],
+ ..Default::default()
+ }),
+ ..Default::default()
+ }
+ }
+
+ fn secret_named(id: &AgentIdentity, name: &str, age_secs: i64, now: DateTime) -> Secret {
+ Secret {
+ metadata: ObjectMeta {
+ name: Some(name.into()),
+ labels: Some(id.labels()),
+ annotations: Some(
+ [(
+ ANNOTATION_PUBKEY_FULL.to_string(),
+ id.pubkey_hex().to_string(),
+ )]
+ .into_iter()
+ .collect::>(),
+ ),
+ creation_timestamp: Some(Time(now - chrono::Duration::seconds(age_secs))),
+ ..Default::default()
+ },
+ ..Default::default()
+ }
+ }
+
+ fn never(_: &Pod) -> bool {
+ false
+ }
+ fn always(_: &Pod) -> bool {
+ true
+ }
+
+ #[test]
+ fn terminated_pods_and_their_secrets_are_collected_together() {
+ let id = identity();
+ let now = Utc::now();
+ let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
+ let plan = plan(&id, &[pod], &[], always, Some(now));
+ assert_eq!(plan.pods, ["buzz-agent-dead"]);
+ assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
+ }
+
+ #[test]
+ fn live_pods_are_never_collected() {
+ let id = identity();
+ let pod = pod_named(&id, "buzz-agent-live", Some("buzz-agent-live-gen1"));
+ let plan = plan(&id, &[pod], &[], never, Some(Utc::now()));
+ assert_eq!(plan, GcPlan::default());
+ }
+
+ /// The auto-repair fence applies to GC identically: an object that lacks
+ /// the marker, or carries a different pubkey, is never touched — however
+ /// well its labels match.
+ #[test]
+ fn unmarked_and_mismatched_objects_are_never_collected() {
+ let id = identity();
+ let other = identity();
+ let now = Utc::now();
+
+ let mut unmarked = pod_named(&id, "look-alike", Some("look-alike-gen1"));
+ let mut labels = id.labels();
+ labels.remove(LABEL_MANAGED_BY);
+ unmarked.metadata.labels = Some(labels);
+
+ let mut foreign = pod_named(&id, "someone-elses", Some("someone-elses-gen1"));
+ foreign.metadata.annotations = Some(
+ [(
+ ANNOTATION_PUBKEY_FULL.to_string(),
+ other.pubkey_hex().to_string(),
+ )]
+ .into_iter()
+ .collect(),
+ );
+
+ let mut unmarked_secret = secret_named(&id, "orphan-unmarked", 100_000, now);
+ unmarked_secret.metadata.labels = Some(BTreeMap::new());
+ let mut foreign_secret = secret_named(&id, "orphan-foreign", 100_000, now);
+ foreign_secret.metadata.annotations = Some(
+ [(
+ ANNOTATION_PUBKEY_FULL.to_string(),
+ other.pubkey_hex().to_string(),
+ )]
+ .into_iter()
+ .collect(),
+ );
+
+ let plan = plan(
+ &id,
+ &[unmarked, foreign],
+ &[unmarked_secret, foreign_secret],
+ always,
+ Some(now),
+ );
+ assert_eq!(
+ plan,
+ GcPlan::default(),
+ "GC touched an object it does not own"
+ );
+ }
+
+ /// The interleaving the age gate exists to close: attempt A creates its
+ /// Secret; concurrent attempt B's preflight GC runs before A creates its
+ /// pod. Without the gate B deletes A's Secret and strands A.
+ #[test]
+ fn young_unreferenced_secrets_are_protected() {
+ let id = identity();
+ let now = Utc::now();
+ let fresh = secret_named(&id, "buzz-agent-x-gen-inflight", 5, now);
+ assert_eq!(
+ plan(&id, &[], &[fresh], never, Some(now)),
+ GcPlan::default()
+ );
+ }
+
+ /// Past twice the deadline, any attempt that could still reference the
+ /// Secret has exceeded its own deadline — so it is provably abandoned.
+ #[test]
+ fn secrets_older_than_twice_the_deadline_are_collected() {
+ let id = identity();
+ let now = Utc::now();
+ let old = secret_named(
+ &id,
+ "buzz-agent-x-gen-abandoned",
+ ORPHAN_SECRET_MIN_AGE_SECS + 1,
+ now,
+ );
+ let plan = plan(&id, &[], &[old], never, Some(now));
+ assert_eq!(plan.secrets, ["buzz-agent-x-gen-abandoned"]);
+ }
+
+ /// The boundary itself, both sides. `>= 1200s` is eligible.
+ #[test]
+ fn age_gate_boundary_is_exact() {
+ let id = identity();
+ let now = Utc::now();
+ let just_under = secret_named(&id, "under", ORPHAN_SECRET_MIN_AGE_SECS - 1, now);
+ let exactly = secret_named(&id, "exact", ORPHAN_SECRET_MIN_AGE_SECS, now);
+ assert!(plan(&id, &[], &[just_under], never, Some(now))
+ .secrets
+ .is_empty());
+ assert_eq!(
+ plan(&id, &[], &[exactly], never, Some(now)).secrets,
+ ["exact"]
+ );
+ }
+
+ /// The same-clock rule. No apiserver `Date` header → skip the orphan
+ /// sweep entirely. A local clock fast by more than the margin would
+ /// silently delete every in-flight Secret on every pass.
+ #[test]
+ fn without_a_server_clock_the_orphan_sweep_is_skipped() {
+ let id = identity();
+ let now = Utc::now();
+ let ancient = secret_named(&id, "buzz-agent-x-gen-ancient", 10_000_000, now);
+ let plan = plan(&id, &[], &[ancient], never, None);
+ assert!(
+ plan.secrets.is_empty(),
+ "orphan swept without a server clock — a fast local clock would delete live Secrets"
+ );
+ }
+
+ /// ...but terminated-pod GC does not consult the clock, so it still runs.
+ #[test]
+ fn terminated_pod_gc_runs_without_a_server_clock() {
+ let id = identity();
+ let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
+ let plan = plan(&id, &[pod], &[], always, None);
+ assert_eq!(plan.pods, ["buzz-agent-dead"]);
+ assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
+ }
+
+ /// "Existing" includes not-yet-started pods: a Secret referenced by a pod
+ /// still pulling its image must not be swept, however old it is.
+ #[test]
+ fn secrets_referenced_by_a_pending_pod_are_protected() {
+ let id = identity();
+ let now = Utc::now();
+ let pending = pod_named(&id, "buzz-agent-pending", Some("buzz-agent-pending-gen1"));
+ let old = secret_named(&id, "buzz-agent-pending-gen1", 10_000_000, now);
+ let plan = plan(&id, &[pending], &[old], never, Some(now));
+ assert!(plan.secrets.is_empty(), "swept a referenced Secret");
+ }
+
+ /// A Secret with no server-assigned creationTimestamp has no age proof,
+ /// so it is skipped rather than assumed old.
+ #[test]
+ fn secrets_without_a_creation_timestamp_are_skipped() {
+ let id = identity();
+ let now = Utc::now();
+ let mut no_timestamp = secret_named(&id, "buzz-agent-x-gen-unknown", 10_000_000, now);
+ no_timestamp.metadata.creation_timestamp = None;
+ assert!(plan(&id, &[], &[no_timestamp], never, Some(now))
+ .secrets
+ .is_empty());
+ }
+
+ /// A Secret belonging to a pod being collected in this same pass goes with
+ /// it, and must not be listed twice.
+ #[test]
+ fn a_collected_pods_secret_is_listed_once() {
+ let id = identity();
+ let now = Utc::now();
+ let dead = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
+ let its_secret = secret_named(&id, "buzz-agent-dead-gen1", 10_000_000, now);
+ let plan = plan(&id, &[dead], &[its_secret], always, Some(now));
+ assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/image.rs b/crates/buzz-backend-kubernetes/src/image.rs
new file mode 100644
index 0000000000..409989fda9
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/image.rs
@@ -0,0 +1,184 @@
+//! Image reference validation (spec §Image).
+//!
+//! The object holding this reference runs with an nsec, so the reference must
+//! be **immutable**. Registry tags are mutable pointers — Kubernetes itself
+//! distinguishes them from digests for exactly this reason — so a tag-only
+//! reference is rejected, not just `:latest`.
+//!
+//! There is no parse-time fallback: `image` is required, and its absence
+//! fails closed with a named field. The published `ghcr.io/block/buzz-sprig`
+//! digest is offered only as a schema `default` (a UI prefill the desktop
+//! submits explicitly — see `config::DEFAULT_IMAGE`), so the create-intent
+//! fingerprint never depends on compiled-in provider state.
+
+/// A validated, digest-qualified image reference.
+///
+/// The inner string is always in canonical tagless form `name@sha256:`:
+/// `name:tag@sha256:…` normalizes by dropping the tag, because the tag is
+/// decorative once a digest pins the content, and leaving it in would make
+/// two references to identical bytes produce different create-intent
+/// fingerprints.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ImageRef(String);
+
+impl ImageRef {
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl std::fmt::Display for ImageRef {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+/// Parse and normalize a user-supplied image reference.
+pub fn parse(raw: &str) -> Result {
+ let reference = raw.trim();
+ if reference.is_empty() {
+ return Err("provider_config.image is required: no image is assumed \
+ at parse time, so the digest-pinned image to run must be \
+ given explicitly"
+ .to_string());
+ }
+
+ let mut parts = reference.split('@');
+ let name_and_tag = parts.next().unwrap_or_default();
+ let digest = match (parts.next(), parts.next()) {
+ (Some(d), None) => d,
+ (None, _) => {
+ return Err(format!(
+ "provider_config.image {reference:?} is not digest-pinned: a \
+ tag is a mutable pointer, and this object runs with the \
+ agent's private key. Use name@sha256:<64 hex chars>"
+ ))
+ }
+ (Some(_), Some(_)) => {
+ return Err(format!(
+ "provider_config.image {reference:?} contains more than one '@'"
+ ))
+ }
+ };
+
+ let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
+ format!("provider_config.image digest {digest:?} must start with 'sha256:'")
+ })?;
+ // Lowercase only: OCI canonicalizes digest hex, and accepting uppercase
+ // would let two spellings of one digest produce two fingerprints.
+ if hex.len() != 64
+ || !hex
+ .chars()
+ .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
+ {
+ return Err(format!(
+ "provider_config.image digest {digest:?} must be exactly 64 \
+ lowercase hex characters"
+ ));
+ }
+
+ // Drop any tag: `name:tag@sha256:…` and `name@sha256:…` name the same
+ // bytes and must fingerprint identically. Only a *final* colon segment
+ // that isn't a port counts as a tag — `host:5000/name` has no tag.
+ let name = match name_and_tag.rfind(':') {
+ Some(colon) if !name_and_tag[colon + 1..].contains('/') => &name_and_tag[..colon],
+ _ => name_and_tag,
+ };
+ if name.is_empty() {
+ return Err(format!(
+ "provider_config.image {reference:?} has no repository name"
+ ));
+ }
+
+ Ok(ImageRef(format!("{name}@sha256:{hex}")))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn accepts_digest_pinned_reference() {
+ let d = "a".repeat(64);
+ let r = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap();
+ assert_eq!(r.as_str(), format!("ghcr.io/block/buzz-sprig@sha256:{d}"));
+ }
+
+ /// The normalization that keeps the fingerprint stable: two spellings of
+ /// the same bytes must produce one reference.
+ #[test]
+ fn strips_tag_from_tag_plus_digest_form() {
+ let d = "b".repeat(64);
+ let tagged = parse(&format!("ghcr.io/block/buzz-sprig:v1.2@sha256:{d}")).unwrap();
+ let plain = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap();
+ assert_eq!(tagged, plain);
+ }
+
+ /// A registry port is not a tag. `host:5000/name` must keep its port.
+ #[test]
+ fn registry_port_is_not_mistaken_for_a_tag() {
+ let d = "c".repeat(64);
+ let r = parse(&format!("localhost:5000/buzz-sprig@sha256:{d}")).unwrap();
+ assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}"));
+ }
+
+ #[test]
+ fn port_and_tag_together_drops_only_the_tag() {
+ let d = "d".repeat(64);
+ let r = parse(&format!("localhost:5000/buzz-sprig:dev@sha256:{d}")).unwrap();
+ assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}"));
+ }
+
+ /// Wren's amendment: *every* tag-only reference is rejected, not just
+ /// `:latest`. A `sha-` tag is traceable but still movable.
+ #[test]
+ fn rejects_every_tag_only_reference() {
+ for bad in [
+ "ghcr.io/block/buzz-sprig:latest",
+ "ghcr.io/block/buzz-sprig:v1.2.3",
+ "ghcr.io/block/buzz-sprig:sha-abc1234",
+ "ghcr.io/block/buzz-sprig",
+ "localhost:5000/buzz-sprig",
+ ] {
+ let err = parse(bad).unwrap_err();
+ assert!(err.contains("digest-pinned"), "for {bad:?} got: {err}");
+ }
+ }
+
+ /// Uppercase hex is a second spelling of one digest; accepting it would
+ /// let the same image fingerprint two ways.
+ #[test]
+ fn rejects_uppercase_digest_hex() {
+ let d = "A".repeat(64);
+ assert!(parse(&format!("img@sha256:{d}")).is_err());
+ }
+
+ #[test]
+ fn rejects_malformed_digests() {
+ let short = "a".repeat(63);
+ let long = "a".repeat(65);
+ let ok = "a".repeat(64);
+ for bad in [
+ format!("img@sha256:{short}"),
+ format!("img@sha256:{long}"),
+ format!("img@sha512:{ok}"),
+ format!("img@{ok}"),
+ format!("img@sha256:{}", "g".repeat(64)),
+ format!("img@sha256:{ok}@sha256:{ok}"),
+ format!("@sha256:{ok}"),
+ ] {
+ assert!(parse(&bad).is_err(), "accepted {bad:?}");
+ }
+ }
+
+ /// Parsing has no fallback (the schema default is a UI prefill, not a
+ /// parse-time substitute), so an absent image is an error that names the
+ /// field rather than a silent fallback.
+ #[test]
+ fn empty_reference_names_the_field() {
+ for empty in ["", " "] {
+ let err = parse(empty).unwrap_err();
+ assert!(err.contains("provider_config.image"), "got: {err}");
+ }
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/intent.rs b/crates/buzz-backend-kubernetes/src/intent.rs
new file mode 100644
index 0000000000..73218f83bf
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/intent.rs
@@ -0,0 +1,309 @@
+//! The create-intent fingerprint (spec §Deploy State Machine, create-intent
+//! fingerprint, `docs/remote-agents.md:796-828`).
+//!
+//! The fingerprint is an unkeyed SHA-256 over a canonical serialization of the
+//! provider's non-secret create-intent template. A plain hash is safe *only*
+//! because of the scope rule: the input covers exactly the provider-controlled
+//! fields that can affect scheduling or container creation, and **never Secret
+//! data or attempt identity**. Hashing low-entropy secrets into a
+//! world-readable annotation would be a dictionary oracle.
+//!
+//! That rule is enforced structurally rather than remembered. [`IntentTemplate`]
+//! is a *pre-binding* type: it has no field that can hold Secret material or a
+//! generation token, so there is no expression that hashes one. The
+//! per-attempt Secret name never appears — the pod's `envFrom` is represented
+//! by the fixed [`SECRET_PLACEHOLDER`], because otherwise every attempt would
+//! diverge from every other by construction.
+//!
+//! Server- and admission-produced output (UID, `resourceVersion`, timestamps,
+//! defaulted fields, the annotation itself) is excluded the same way: the
+//! serializer is only ever handed this template, never a live `Pod`, so the
+//! exclusion is checkable by inspection.
+
+use crate::image::ImageRef;
+use serde::Serialize;
+use sha2::{Digest, Sha256};
+
+/// Stands in for the per-attempt Secret name in the `envFrom` position.
+/// A real generation token here would make every attempt diverge.
+const SECRET_PLACEHOLDER: &str = "";
+
+/// The recorded/computed create intent: a hex SHA-256 digest.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Fingerprint(String);
+
+impl Fingerprint {
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+
+ /// Read a fingerprint off a pod annotation. Any recorded string is
+ /// accepted verbatim: comparison is equality against a freshly computed
+ /// value, so a malformed annotation simply reads as divergence — which is
+ /// the correct outcome for a pod this provider version did not write.
+ pub fn from_annotation(value: &str) -> Self {
+ Self(value.to_string())
+ }
+
+ #[cfg(test)]
+ pub fn for_test(seed: &str) -> Self {
+ Self(format!("test-{seed}"))
+ }
+}
+
+impl std::fmt::Display for Fingerprint {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+/// The non-secret, pre-binding description of the pod this deploy would
+/// create. Every field is provider-controlled and scheduling-relevant; there
+/// is deliberately no field for env values, Secret data, or the generation.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct IntentTemplate {
+ /// Schema version of the template itself. Bumping it re-fingerprints every
+ /// pod, which is the intended way to roll out a pod-shape change.
+ pub template_version: u32,
+ pub namespace: String,
+ /// Normalized, digest-qualified image reference.
+ pub image: String,
+ pub cpu_request: String,
+ pub memory_request: String,
+ pub cpu_limit: String,
+ pub memory_limit: String,
+ pub service_account: Option,
+ pub restart_policy: &'static str,
+ pub termination_grace_period_seconds: i64,
+ /// Env *keys* only, sorted. Keys are pod-shape (a renamed key changes the
+ /// container's contract); values are Secret material and must not be here.
+ pub env_keys: Vec,
+ /// Fixed placeholder for the per-attempt Secret in `envFrom`.
+ pub env_from_secret: &'static str,
+ pub workspace_mount_path: String,
+ pub run_as_user: i64,
+ pub run_as_group: i64,
+}
+
+/// Current template schema version.
+pub const TEMPLATE_VERSION: u32 = 1;
+
+impl IntentTemplate {
+ /// Compute the fingerprint. `serde_json` on a struct with declared field
+ /// order plus pre-sorted `env_keys` is a canonical serialization: the same
+ /// template always produces the same bytes.
+ pub fn fingerprint(&self) -> Fingerprint {
+ let canonical = serde_json::to_vec(self).expect("intent template is plain data");
+ Fingerprint(hex::encode(Sha256::digest(&canonical)))
+ }
+
+ /// Build from resolved pod-shape inputs. `env_keys` is sorted here rather
+ /// than at the call site so key ordering can never leak into the digest.
+ ///
+ /// The fixed pod-shape constants are read from [`crate::config`] rather
+ /// than passed in: `pod::build_pod` stamps the pod from those same
+ /// constants, so the fingerprint cannot describe a pod shape different
+ /// from the one actually created. Threading them through as arguments
+ /// would make that agreement a thing to test instead of a thing that holds.
+ pub fn new(
+ namespace: &str,
+ image: &ImageRef,
+ resources: &crate::config::Resources,
+ service_account: Option<&str>,
+ env_keys: impl IntoIterator- ,
+ ) -> Self {
+ let mut env_keys: Vec
= env_keys.into_iter().collect();
+ env_keys.sort();
+ Self {
+ template_version: TEMPLATE_VERSION,
+ namespace: namespace.to_string(),
+ image: image.as_str().to_string(),
+ cpu_request: resources.cpu_request.clone(),
+ memory_request: resources.memory_request.clone(),
+ cpu_limit: resources.cpu_limit.clone(),
+ memory_limit: resources.memory_limit.clone(),
+ service_account: service_account.map(str::to_string),
+ restart_policy: crate::config::RESTART_POLICY,
+ termination_grace_period_seconds: crate::config::TERMINATION_GRACE_SECONDS,
+ env_keys,
+ env_from_secret: SECRET_PLACEHOLDER,
+ workspace_mount_path: crate::config::WORKSPACE_PATH.to_string(),
+ run_as_user: crate::config::RUN_AS_UID,
+ run_as_group: crate::config::RUN_AS_GID,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::Resources;
+
+ fn image(byte: char) -> ImageRef {
+ crate::image::parse(&format!(
+ "ghcr.io/block/buzz-sprig@sha256:{}",
+ byte.to_string().repeat(64)
+ ))
+ .unwrap()
+ }
+
+ fn template() -> IntentTemplate {
+ IntentTemplate::new(
+ "buzz-agents",
+ &image('a'),
+ &Resources::default(),
+ None,
+ ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()],
+ )
+ }
+
+ #[test]
+ fn fingerprint_is_deterministic() {
+ assert_eq!(template().fingerprint(), template().fingerprint());
+ }
+
+ #[test]
+ fn fingerprint_is_hex_sha256() {
+ let fp = template().fingerprint();
+ assert_eq!(fp.as_str().len(), 64);
+ assert!(fp.as_str().chars().all(|c| c.is_ascii_hexdigit()));
+ }
+
+ /// Key *order* must not reach the digest, or two identical environments
+ /// built in different orders would look like a config change.
+ #[test]
+ fn env_key_order_does_not_affect_the_digest() {
+ let a = IntentTemplate::new(
+ "ns",
+ &image('a'),
+ &Resources::default(),
+ None,
+ ["A".to_string(), "B".to_string(), "C".to_string()],
+ );
+ let b = IntentTemplate::new(
+ "ns",
+ &image('a'),
+ &Resources::default(),
+ None,
+ ["C".to_string(), "A".to_string(), "B".to_string()],
+ );
+ assert_eq!(a.fingerprint(), b.fingerprint());
+ }
+
+ /// A mutation applied to a fresh template clone, named for its assertion
+ /// message.
+ type Mutation = (&'static str, Box);
+
+ /// Every scheduling-relevant knob must move the digest — this is the
+ /// wedge escape (§Deploy State Machine never-started recoverable row).
+ /// Exhaustive by construction: each mutation is applied to a fresh clone.
+ #[test]
+ fn every_scheduling_field_changes_the_digest() {
+ let base = template();
+ let baseline = base.fingerprint();
+
+ let mutations: Vec = vec![
+ (
+ "template_version",
+ Box::new(|t: &mut IntentTemplate| t.template_version += 1),
+ ),
+ (
+ "namespace",
+ Box::new(|t: &mut IntentTemplate| t.namespace = "other".into()),
+ ),
+ (
+ "image",
+ Box::new(|t: &mut IntentTemplate| t.image = image('b').as_str().into()),
+ ),
+ (
+ "cpu_request",
+ Box::new(|t: &mut IntentTemplate| t.cpu_request = "4".into()),
+ ),
+ (
+ "memory_request",
+ Box::new(|t: &mut IntentTemplate| t.memory_request = "8Gi".into()),
+ ),
+ (
+ "cpu_limit",
+ Box::new(|t: &mut IntentTemplate| t.cpu_limit = "8".into()),
+ ),
+ (
+ "memory_limit",
+ Box::new(|t: &mut IntentTemplate| t.memory_limit = "16Gi".into()),
+ ),
+ (
+ "service_account",
+ Box::new(|t: &mut IntentTemplate| t.service_account = Some("sa".into())),
+ ),
+ (
+ "restart_policy",
+ Box::new(|t: &mut IntentTemplate| t.restart_policy = "OnFailure"),
+ ),
+ (
+ "grace_period",
+ Box::new(|t: &mut IntentTemplate| t.termination_grace_period_seconds = 30),
+ ),
+ (
+ "env_keys",
+ Box::new(|t: &mut IntentTemplate| t.env_keys.push("NEW_KEY".into())),
+ ),
+ (
+ "workspace_mount_path",
+ Box::new(|t: &mut IntentTemplate| t.workspace_mount_path = "/w".into()),
+ ),
+ (
+ "run_as_user",
+ Box::new(|t: &mut IntentTemplate| t.run_as_user = 2000),
+ ),
+ (
+ "run_as_group",
+ Box::new(|t: &mut IntentTemplate| t.run_as_group = 2000),
+ ),
+ ];
+
+ for (name, mutate) in mutations {
+ let mut t = base.clone();
+ mutate(&mut t);
+ assert_ne!(
+ t.fingerprint(),
+ baseline,
+ "{name} did not affect the digest"
+ );
+ }
+ }
+
+ /// The scope rule, asserted on the bytes: no Secret value and no
+ /// generation token can appear in the serialization, because the type has
+ /// nowhere to put them. The placeholder is what `envFrom` contributes.
+ #[test]
+ fn serialization_contains_no_secret_material_or_attempt_identity() {
+ let json = serde_json::to_string(&template()).unwrap();
+ for forbidden in ["nsec1", "SPOOFED", "wss://", "gen0001"] {
+ assert!(
+ !json.contains(forbidden),
+ "template leaked {forbidden}: {json}"
+ );
+ }
+ assert!(json.contains(SECRET_PLACEHOLDER));
+ }
+
+ /// Two attempts for the same agent differ only in generation, which is
+ /// absent from the template — so their fingerprints must be equal, or the
+ /// divergence discriminator would fire on every single deploy.
+ #[test]
+ fn attempts_differing_only_by_generation_do_not_diverge() {
+ // There is no generation input to pass; that *is* the property. The
+ // test states it explicitly so a future field addition breaks here.
+ assert_eq!(template().fingerprint(), template().fingerprint());
+ let json = serde_json::to_string(&template()).unwrap();
+ assert_eq!(json.matches(SECRET_PLACEHOLDER).count(), 1);
+ }
+
+ /// A recorded annotation this provider version did not write reads as
+ /// divergence rather than an error.
+ #[test]
+ fn unrecognized_annotation_reads_as_divergence() {
+ let recorded = Fingerprint::from_annotation("not-a-digest");
+ assert_ne!(recorded, template().fingerprint());
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/main.rs b/crates/buzz-backend-kubernetes/src/main.rs
new file mode 100644
index 0000000000..5d521a9d37
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/main.rs
@@ -0,0 +1,199 @@
+//! Kubernetes backend provider for Buzz remote agents
+//! (spec `docs/remote-agents.md`).
+//!
+//! One process per operation: read exactly one JSON request from stdin, write
+//! exactly one JSON response to stdout, exit. The exit code carries exactly
+//! one bit — 0 for a response that was produced, 1 for a failure to produce
+//! one. Everything a caller needs to distinguish is *inside* the response's
+//! `ok` field, because a provider that encoded outcomes in exit codes would
+//! have a second, redundant error channel to keep in sync (§Provider Protocol).
+
+mod classify;
+mod client;
+mod cluster;
+mod config;
+mod env;
+mod gc;
+mod image;
+mod intent;
+mod naming;
+mod observe;
+mod pod;
+mod reconcile;
+mod wire;
+
+use std::io::Read;
+use wire::{Request, Response};
+
+/// The provider a shared-compute agent resolves to. Refused here as the
+/// spec's backstop: a mesh agent runs on the relay's compute, so deploying it
+/// as a pod would create a second, contending consumer of the same agent
+/// identity (`:214-219`).
+const RELAY_MESH_PROVIDER: &str = "relay-mesh";
+
+fn main() {
+ // rustls needs a process-level provider before the first TLS connection.
+ // The release build compiles every sidecar in one cargo invocation, which
+ // unifies the `ring` and `aws-lc-rs` features and leaves rustls unable to
+ // auto-select — so this is an explicit install, not a default.
+ let _ = rustls::crypto::ring::default_provider().install_default();
+
+ let mut input = String::new();
+ if let Err(e) = std::io::stdin().read_to_string(&mut input) {
+ // No request means no request_id and no response contract to honor.
+ // This is the one path that exits nonzero.
+ eprintln!("could not read the request from stdin: {e}");
+ std::process::exit(1);
+ }
+
+ let response = respond(&input);
+ println!(
+ "{}",
+ serde_json::to_string(&response).unwrap_or_else(|e| {
+ // The response types are plain data; this cannot fail in practice,
+ // and a hand-built object is still a conforming response.
+ format!(r#"{{"ok":false,"error":"could not serialize a response: {e}"}}"#)
+ })
+ );
+}
+
+/// Produce the single response for one request. Separated from `main` so the
+/// whole dispatch is testable without a process.
+fn respond(input: &str) -> Response {
+ // Parsed as raw JSON first: the relay-mesh refusal below MUST see the wire
+ // value, and `AgentPayload` deliberately does not carry `provider`.
+ let raw: serde_json::Value = match serde_json::from_str(input) {
+ Ok(value) => value,
+ Err(e) => return Response::error(format!("request is not valid JSON: {e}")),
+ };
+
+ if let Some(refusal) = refuse_relay_mesh(&raw) {
+ return Response::error(refusal);
+ }
+
+ let request: Request = match serde_json::from_value(raw) {
+ Ok(request) => request,
+ Err(e) => return Response::error(format!("could not understand the request: {e}")),
+ };
+
+ match request {
+ Request::Info => Response::info(),
+ Request::Deploy(deploy) => {
+ let runtime = match tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ {
+ Ok(runtime) => runtime,
+ Err(e) => return Response::error(format!("could not start the runtime: {e}")),
+ };
+ match runtime.block_on(deploy_agent(&deploy)) {
+ Ok(agent_id) => Response::deployed(agent_id),
+ Err(e) => Response::error(e),
+ }
+ }
+ }
+}
+
+/// Refuse a shared-compute agent, reading the **raw wire value**.
+///
+/// Trimmed before comparing: the desktop's own layers disagree about padding
+/// (`relay_mesh.rs:17` and `effective_config/mod.rs:46` trim; the deploy guard
+/// at `agents_deploy.rs:116` did not), and `non_blank` preserves surrounding
+/// whitespace on a non-blank value. A backstop that shares its bypass with the
+/// layer it backs is not a backstop.
+fn refuse_relay_mesh(raw: &serde_json::Value) -> Option {
+ let provider = raw.get("agent")?.get("provider")?.as_str()?;
+ (provider.trim() == RELAY_MESH_PROVIDER).then(|| {
+ "deploy refused: this agent is configured for shared compute \
+ (relay-mesh), which runs on the relay rather than in a pod. \
+ Switch the agent to a local runtime before deploying it to \
+ Kubernetes."
+ .to_string()
+ })
+}
+
+/// Run one deploy to a terminal outcome.
+async fn deploy_agent(request: &wire::DeployRequest) -> Result {
+ let cfg = config::parse(&request.provider_config)?;
+ // Identity before any cluster contact: a malformed nsec is a refusal, not
+ // a failed connection (§Deploy State Machine step 0).
+ let identity = naming::AgentIdentity::from_nsec(&request.agent.private_key_nsec)?;
+
+ // One generation for this operation's first attempt; the reconciler mints
+ // its own per attempt and restamps the correlator to match.
+ let env = env::build_env(
+ &request.agent,
+ env::AuthoritativeInputs {
+ generation: &naming::new_generation(),
+ inactivity_seconds: cfg.inactivity_seconds,
+ },
+ )?;
+
+ let client = client::connect(cfg.context.as_deref()).await?;
+ let substrate = cluster::Cluster::new(client, &cfg.namespace);
+ reconcile::deploy(&substrate, &identity, &cfg, env).await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn error_of(response: &Response) -> String {
+ let json = serde_json::to_value(response).unwrap();
+ assert_eq!(json["ok"], false, "expected a refusal: {json}");
+ json["error"].as_str().unwrap().to_string()
+ }
+
+ /// The spec's backstop for the relay-mesh MUST. The desktop refuses first
+ /// (`agents_deploy.rs:116`); this is the layer that owes the obligation.
+ #[test]
+ fn refuses_a_relay_mesh_agent() {
+ let request = r#"{"op":"deploy","agent":{
+ "relay_url":"wss://r","private_key_nsec":"nsec1x","provider":"relay-mesh"},
+ "provider_config":{"namespace":"ns"}}"#;
+ assert!(error_of(&respond(request)).contains("relay-mesh"));
+ }
+
+ /// Padding must not bypass the backstop. Reachable by construction:
+ /// `GlobalConfig.provider` is a bare `Option` with no trim on
+ /// write, and `non_blank` rejects whitespace-only while preserving
+ /// surrounding whitespace on everything else.
+ #[test]
+ fn refuses_a_padded_relay_mesh_agent() {
+ let request = r#"{"op":"deploy","agent":{
+ "relay_url":"wss://r","private_key_nsec":"nsec1x","provider":" relay-mesh "},
+ "provider_config":{"namespace":"ns"}}"#;
+ assert!(error_of(&respond(request)).contains("relay-mesh"));
+ }
+
+ /// The refusal must not fire on a normal agent — a guard that refuses
+ /// everything passes its own test and ships a provider that deploys
+ /// nothing.
+ #[test]
+ fn does_not_refuse_a_normal_provider() {
+ let raw: serde_json::Value =
+ serde_json::from_str(r#"{"agent":{"provider":"openai"}}"#).unwrap();
+ assert!(refuse_relay_mesh(&raw).is_none());
+ // …nor when the field is absent entirely, which is the common case:
+ // `AgentPayload` does not carry `provider`.
+ let bare: serde_json::Value = serde_json::from_str(r#"{"agent":{}}"#).unwrap();
+ assert!(refuse_relay_mesh(&bare).is_none());
+ }
+
+ /// Malformed input still produces exactly one conforming response.
+ #[test]
+ fn malformed_input_is_an_in_band_error() {
+ assert!(error_of(&respond("not json")).contains("valid JSON"));
+ assert!(error_of(&respond(r#"{"op":"undeploy"}"#)).contains("understand"));
+ }
+
+ /// `info` answers without touching a cluster — it is what the desktop
+ /// calls to render the config form, before any kubeconfig exists.
+ #[test]
+ fn info_answers_with_the_protocol_version_and_schema() {
+ let json = serde_json::to_value(respond(r#"{"op":"info"}"#)).unwrap();
+ assert_eq!(json["ok"], true);
+ assert_eq!(json["protocol_version"], wire::PROTOCOL_VERSION);
+ assert!(json["config_schema"]["properties"]["namespace"].is_object());
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/naming.rs b/crates/buzz-backend-kubernetes/src/naming.rs
new file mode 100644
index 0000000000..4b9d7ea035
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/naming.rs
@@ -0,0 +1,223 @@
+//! Identity derivation and the object-naming contract (spec §Pod shape).
+//!
+//! Every name, label, and annotation below is derived from the pubkey the
+//! provider decoded itself from `private_key_nsec` — never from a
+//! caller-supplied pubkey (§Deploy State Machine step 0).
+
+use nostr::nips::nip19::FromBech32;
+
+/// `app.kubernetes.io/managed-by` value: the management marker's identity half.
+pub const MANAGED_BY: &str = "buzz-backend-kubernetes";
+
+/// Label key carrying [`MANAGED_BY`].
+pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by";
+
+/// Label key carrying [`BINDING_VERSION`] — the marker's schema half.
+pub const LABEL_BINDING_VERSION: &str = "buzz.block.xyz/binding-version";
+
+/// Schema version of the object layout this provider writes. Bumped when the
+/// pod/Secret shape changes in a way a older provider would mis-handle.
+pub const BINDING_VERSION: &str = "1";
+
+/// Label key: truncated pubkey, the reconciliation and GC selector.
+pub const LABEL_AGENT_PUBKEY: &str = "buzz.block.xyz/agent-pubkey";
+
+/// Annotation key: full pubkey. Load-bearing — the truncated label is
+/// collision-*resistant*, this is what makes it safe (§Deploy State Machine
+/// step 1).
+pub const ANNOTATION_PUBKEY_FULL: &str = "buzz.block.xyz/agent-pubkey-full";
+
+/// Annotation key: the recorded create-intent fingerprint.
+pub const ANNOTATION_CREATE_INTENT: &str = "buzz.block.xyz/create-intent";
+
+/// Annotation key: the image reference this generation actually resolved to,
+/// for post-hoc attribution (§Image).
+pub const ANNOTATION_IMAGE: &str = "buzz.block.xyz/image";
+
+/// An agent identity the provider derived itself, plus every name it implies.
+///
+/// Constructing this type is the *only* way to obtain the names — so a
+/// caller-supplied pubkey cannot reach a selector by any path.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AgentIdentity {
+ pubkey_hex: String,
+}
+
+impl AgentIdentity {
+ /// Derive from the payload's `private_key_nsec`.
+ ///
+ /// Accepts bech32 `nsec1…`; a malformed or undecodable key is an
+ /// immediate error, before any substrate read or mutation
+ /// (§Deploy State Machine step 0).
+ pub fn from_nsec(nsec: &str) -> Result {
+ let secret = nostr::SecretKey::from_bech32(nsec.trim())
+ .map_err(|_| "private_key_nsec is not a decodable nsec1 key".to_string())?;
+ let keys = nostr::Keys::new(secret);
+ Ok(Self {
+ pubkey_hex: keys.public_key().to_hex(),
+ })
+ }
+
+ /// Full 64-hex public key — the annotation value and the comparison
+ /// operand for candidate authentication.
+ pub fn pubkey_hex(&self) -> &str {
+ &self.pubkey_hex
+ }
+
+ /// Selector label value: first 32 hex chars (128 bits). A full hex pubkey
+ /// is 64 chars and label values cap at 63, which is why this is truncated
+ /// and why the annotation check is normative rather than decorative.
+ pub fn label_pubkey(&self) -> &str {
+ &self.pubkey_hex[..32]
+ }
+
+ /// Deterministic pod name, also the returned `agent_id`.
+ pub fn pod_name(&self) -> String {
+ format!("buzz-agent-{}", &self.pubkey_hex[..12])
+ }
+
+ /// Per-attempt Secret name. `generation` is a fresh random token per
+ /// create attempt — never reused — which is what makes payload and Secret
+ /// atomic at the pod-spec boundary (§K8s Secrets).
+ pub fn secret_name(&self, generation: &str) -> String {
+ format!("buzz-agent-{}-{}", &self.pubkey_hex[..12], generation)
+ }
+
+ /// Label selector matching this identity's objects *and* our management
+ /// marker. Selecting on the marker as well as the identity means an
+ /// unmarked look-alike never even enters the candidate list.
+ pub fn selector(&self) -> String {
+ format!(
+ "{LABEL_AGENT_PUBKEY}={},{LABEL_MANAGED_BY}={MANAGED_BY}",
+ self.label_pubkey()
+ )
+ }
+
+ /// The label set stamped on every object this provider creates.
+ pub fn labels(&self) -> std::collections::BTreeMap {
+ [
+ (
+ LABEL_AGENT_PUBKEY.to_string(),
+ self.label_pubkey().to_string(),
+ ),
+ (LABEL_MANAGED_BY.to_string(), MANAGED_BY.to_string()),
+ (
+ LABEL_BINDING_VERSION.to_string(),
+ BINDING_VERSION.to_string(),
+ ),
+ ]
+ .into_iter()
+ .collect()
+ }
+}
+
+/// A fresh generation token: 8 lowercase hex chars from the OS RNG.
+///
+/// Appears in the Secret name and as `BUZZ_MANAGED_AGENT_START_NONCE`, so the
+/// Secret generation and the harness's lifecycle-frame correlator are one
+/// identity (§Launch data tier 3).
+pub fn new_generation() -> String {
+ use rand::RngExt;
+ let n: u32 = rand::rng().random();
+ format!("{n:08x}")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A fixed test key. Deriving the pubkey (rather than hardcoding both
+ /// halves) is the point: the test exercises the same derivation the
+ /// reconciler depends on.
+ fn identity() -> AgentIdentity {
+ let keys = nostr::Keys::generate();
+ let nsec = {
+ use nostr::nips::nip19::ToBech32;
+ keys.secret_key().to_bech32().unwrap()
+ };
+ let id = AgentIdentity::from_nsec(&nsec).unwrap();
+ assert_eq!(id.pubkey_hex(), keys.public_key().to_hex());
+ id
+ }
+
+ #[test]
+ fn rejects_malformed_nsec() {
+ for bad in ["", "nsec1", "not-a-key", "npub1abc"] {
+ assert!(
+ AgentIdentity::from_nsec(bad).is_err(),
+ "accepted malformed key {bad:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn tolerates_surrounding_whitespace() {
+ let keys = nostr::Keys::generate();
+ use nostr::nips::nip19::ToBech32;
+ let nsec = keys.secret_key().to_bech32().unwrap();
+ let padded = format!(" {nsec}\n");
+ assert_eq!(
+ AgentIdentity::from_nsec(&padded).unwrap().pubkey_hex(),
+ keys.public_key().to_hex()
+ );
+ }
+
+ /// Kubernetes label *values* cap at 63 chars; a full hex pubkey is 64,
+ /// one over. That one-char overflow is the whole reason the selector is
+ /// truncated, so it gets an explicit test.
+ #[test]
+ fn label_value_fits_kubernetes_limit() {
+ let id = identity();
+ assert_eq!(id.pubkey_hex().len(), 64);
+ assert_eq!(id.label_pubkey().len(), 32);
+ assert!(id.label_pubkey().len() <= 63);
+ }
+
+ #[test]
+ fn pod_name_is_deterministic_and_dns_safe() {
+ let id = identity();
+ assert_eq!(id.pod_name(), id.pod_name());
+ assert_eq!(
+ id.pod_name(),
+ format!("buzz-agent-{}", &id.pubkey_hex()[..12])
+ );
+ assert!(id.pod_name().len() <= 253);
+ assert!(id
+ .pod_name()
+ .chars()
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
+ }
+
+ /// Two attempts must never share a Secret name — that uniqueness is what
+ /// stops a losing contender from overwriting the winner's identity.
+ #[test]
+ fn secret_names_are_per_attempt() {
+ let id = identity();
+ let a = id.secret_name(&new_generation());
+ let b = id.secret_name(&new_generation());
+ assert_ne!(a, b);
+ assert!(a.starts_with(&id.pod_name()));
+ assert!(a.len() <= 253);
+ }
+
+ #[test]
+ fn selector_requires_the_management_marker() {
+ let id = identity();
+ let sel = id.selector();
+ assert!(sel.contains(&format!("{LABEL_AGENT_PUBKEY}={}", id.label_pubkey())));
+ assert!(sel.contains(&format!("{LABEL_MANAGED_BY}={MANAGED_BY}")));
+ }
+
+ #[test]
+ fn every_created_object_carries_the_marker() {
+ let labels = identity().labels();
+ assert_eq!(
+ labels.get(LABEL_MANAGED_BY).map(String::as_str),
+ Some(MANAGED_BY)
+ );
+ assert_eq!(
+ labels.get(LABEL_BINDING_VERSION).map(String::as_str),
+ Some(BINDING_VERSION)
+ );
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/observe.rs b/crates/buzz-backend-kubernetes/src/observe.rs
new file mode 100644
index 0000000000..1c6c883563
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/observe.rs
@@ -0,0 +1,592 @@
+//! Decoding API objects into verified observations (spec §Deploy State
+//! Machine step 1).
+//!
+//! Pure: `Pod` in, [`VerifiedPod`] out. Keeping the decode here means the
+//! conformance tests drive the *shipped* decoder with real API types rather
+//! than a test-only stand-in, and it keeps `classify.rs` free of API types.
+//!
+//! Verification is the gate, not a filter: [`verify`] returns `None` for any
+//! object whose full-pubkey annotation does not equal the derived pubkey or
+//! that lacks the management marker, so an unverified object cannot reach
+//! classification, deletion, or the returned `agent_id`.
+
+use crate::classify::{Fence, PullFailure, Startup, VerifiedPod};
+use crate::intent::Fingerprint;
+use crate::naming::{
+ AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, BINDING_VERSION,
+ LABEL_BINDING_VERSION, LABEL_MANAGED_BY, MANAGED_BY,
+};
+use k8s_openapi::api::core::v1::{Pod, Secret};
+
+/// Container name the provider creates; status is read from this container.
+pub const CONTAINER_NAME: &str = "agent";
+
+/// The startup state, or a state that cannot be settled without one more read.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum StartupObservation {
+ Resolved(Startup),
+ /// `CreateContainerConfigError` — recoverable *unless* the referenced
+ /// Secret is confirmed absent by a most-recent read. The kubelet's reason
+ /// string is a hint; the provider verifies before treating it as fatal
+ /// (§Deploy State Machine: "provably" means a verified absence, never a
+ /// reason string).
+ ConfigErrorPendingSecretCheck {
+ secret_name: String,
+ },
+}
+
+/// Does this object carry the management marker (§Pod shape)?
+///
+/// Identity labels prove identity; the marker asserts protocol ownership.
+/// Without it an object that merely matches our schema fails closed.
+fn has_marker(labels: Option<&std::collections::BTreeMap>) -> bool {
+ let Some(labels) = labels else { return false };
+ labels.get(LABEL_MANAGED_BY).map(String::as_str) == Some(MANAGED_BY)
+ && labels.get(LABEL_BINDING_VERSION).map(String::as_str) == Some(BINDING_VERSION)
+}
+
+/// Does the full-pubkey annotation equal the derived pubkey?
+///
+/// The 32-hex label is collision-*resistant*, not collision-free, which is
+/// why this check is normative rather than decorative (`:1152-1166`).
+fn annotation_matches(
+ annotations: Option<&std::collections::BTreeMap>,
+ identity: &AgentIdentity,
+) -> bool {
+ annotations
+ .and_then(|a| a.get(ANNOTATION_PUBKEY_FULL))
+ .map(|v| v == identity.pubkey_hex())
+ .unwrap_or(false)
+}
+
+/// Is this Secret ours and this identity's? The same fence GC applies before
+/// deleting anything.
+pub fn secret_is_ours(secret: &Secret, identity: &AgentIdentity) -> bool {
+ has_marker(secret.metadata.labels.as_ref())
+ && annotation_matches(secret.metadata.annotations.as_ref(), identity)
+}
+
+/// Decode a pod's startup state from its status.
+///
+/// "Started" means `state.running` on our container — not pod phase. A pod can
+/// sit in phase `Running` with a container that never started, and a pod being
+/// gracefully deleted stays in phase `Running` for its whole grace period.
+pub fn decode_startup(pod: &Pod) -> StartupObservation {
+ use StartupObservation::Resolved;
+
+ let status = pod.status.as_ref();
+ let phase = status.and_then(|s| s.phase.as_deref());
+
+ let container = status
+ .and_then(|s| s.container_statuses.as_ref())
+ .and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME));
+
+ if let Some(state) = container.and_then(|c| c.state.as_ref()) {
+ if state.running.is_some() {
+ return Resolved(Startup::Started);
+ }
+ if state.terminated.is_some() {
+ return Resolved(Startup::Terminated);
+ }
+ if let Some(waiting) = state.waiting.as_ref() {
+ let reason = waiting.reason.as_deref().unwrap_or_default();
+ let message = waiting.message.as_deref().unwrap_or_default();
+ return match reason {
+ // Structurally invalid reference: no retry can fix it.
+ "InvalidImageName" => Resolved(Startup::NeverStartedProvablyBroken),
+ "ErrImagePull" | "ImagePullBackOff" => match classify_pull_failure(message) {
+ Some(failure) => Resolved(Startup::NeverStartedPullFailing(failure)),
+ None => Resolved(Startup::NeverStartedRecoverable),
+ },
+ "CreateContainerConfigError" => match referenced_secret(pod) {
+ Some(secret_name) => {
+ StartupObservation::ConfigErrorPendingSecretCheck { secret_name }
+ }
+ None => Resolved(Startup::NeverStartedRecoverable),
+ },
+ _ => Resolved(Startup::NeverStartedRecoverable),
+ };
+ }
+ }
+
+ // No container status yet (unscheduled, image pulling before the kubelet
+ // reports, quota-blocked). A terminal phase without container status still
+ // means the pod is done.
+ match phase {
+ Some("Succeeded") | Some("Failed") => Resolved(Startup::Terminated),
+ _ => Resolved(Startup::NeverStartedRecoverable),
+ }
+}
+
+/// The Secret name this pod's `envFrom` references, if any.
+pub fn referenced_secret(pod: &Pod) -> Option {
+ pod.spec
+ .as_ref()?
+ .containers
+ .iter()
+ .flat_map(|c| c.env_from.iter().flatten())
+ .find_map(|source| source.secret_ref.as_ref().map(|r| r.name.clone()))
+}
+
+/// Classify a pull failure from the kubelet's message.
+///
+/// Reporting only — [`PullFailure`] is structurally excluded from
+/// `Action::Delete`, so a wrong guess here can delay a report but can never
+/// destroy anything. `None` means "no permanent cause recognized", which
+/// leaves the pod on the ordinary observational path.
+fn classify_pull_failure(message: &str) -> Option {
+ let m = message.to_ascii_lowercase();
+ if m.contains("401")
+ || m.contains("unauthorized")
+ || m.contains("403")
+ || m.contains("denied")
+ || m.contains("authentication required")
+ {
+ return Some(PullFailure::Unauthorized);
+ }
+ if m.contains("manifest unknown")
+ || m.contains("not found")
+ || m.contains("manifest_unknown")
+ || m.contains("repository does not exist")
+ {
+ return Some(PullFailure::ManifestUnknown);
+ }
+ if m.contains("no match for platform") || m.contains("no matching manifest") {
+ return Some(PullFailure::ArchMismatch);
+ }
+ None
+}
+
+/// The redacted, actionable condition text for a pull failure.
+///
+/// Names the registry and the immutable reference — never credentials, and
+/// never the kubelet's raw message, which can echo a registry token.
+pub fn pull_failure_message(failure: PullFailure, image: &str) -> String {
+ let registry = image.split('/').next().unwrap_or(image);
+ match failure {
+ PullFailure::Unauthorized => format!(
+ "the cluster is not authorized to pull {image} from {registry}. \
+ This pull retries indefinitely and will not succeed on its own: \
+ grant the cluster's nodes access to that registry."
+ ),
+ PullFailure::ManifestUnknown => {
+ format!("{registry} has no image at {image}. Check the digest and repository.")
+ }
+ PullFailure::ArchMismatch => format!(
+ "{image} has no variant for the architecture of the nodes it was \
+ scheduled on."
+ ),
+ }
+}
+
+/// The latest actionable condition for a pod that has not started, redacted.
+///
+/// Two sources, deliberately treated differently:
+///
+/// * The container's waiting **reason** is included; its **message** is not.
+/// Waiting messages are kubelet-composed and echo the thing that failed —
+/// for a pull that is the registry request, which can carry credential
+/// material. The reason token alone (`ImagePullBackOff`,
+/// `CreateContainerConfigError`) is the diagnostic; the message adds
+/// exposure, not information the user can act on.
+/// * Pod-condition messages **are** included. They are scheduler- and
+/// kubelet-composed from the pod's own spec and cluster capacity
+/// ("0/3 nodes are available: Insufficient memory"), which is precisely the
+/// actionable half and contains nothing derived from Secret data.
+pub fn condition(pod: &Pod) -> Option {
+ let status = pod.status.as_ref()?;
+
+ if let Some(state) = status
+ .container_statuses
+ .as_ref()
+ .and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME))
+ .and_then(|c| c.state.as_ref())
+ {
+ if let Some(waiting) = state.waiting.as_ref() {
+ if let Some(reason) = waiting.reason.as_deref() {
+ return Some(format!("the container is waiting, reason {reason}"));
+ }
+ }
+ // Exit code and reason only — the terminated `message` is
+ // process-composed output and falls under the same redaction rule as
+ // waiting messages.
+ if let Some(terminated) = state.terminated.as_ref() {
+ return Some(match terminated.reason.as_deref() {
+ Some(reason) => format!(
+ "the container exited with code {} ({reason})",
+ terminated.exit_code
+ ),
+ None => format!("the container exited with code {}", terminated.exit_code),
+ });
+ }
+ }
+
+ if let Some((type_, reason, message)) = status.conditions.as_ref().and_then(|cs| {
+ cs.iter().find(|c| c.status == "False").map(|c| {
+ (
+ c.type_.clone(),
+ c.reason.clone().unwrap_or_default(),
+ c.message.clone().unwrap_or_default(),
+ )
+ })
+ }) {
+ let detail = [reason, message]
+ .into_iter()
+ .filter(|s| !s.is_empty())
+ .collect::>()
+ .join(": ");
+ return Some(if detail.is_empty() {
+ format!("pod condition {type_} is false")
+ } else {
+ format!("pod condition {type_} is false: {detail}")
+ });
+ }
+
+ status.phase.as_deref().map(|p| format!("the pod is {p}"))
+}
+
+/// Verify a label-selected pod and decode it, or reject it.
+///
+/// `startup` is supplied by the caller because settling
+/// `CreateContainerConfigError` needs a most-recent Secret read the pure layer
+/// must not perform.
+pub fn verify(pod: &Pod, identity: &AgentIdentity, startup: Startup) -> Option {
+ if !has_marker(pod.metadata.labels.as_ref()) {
+ return None;
+ }
+ if !annotation_matches(pod.metadata.annotations.as_ref(), identity) {
+ return None;
+ }
+ Some(VerifiedPod {
+ name: pod.metadata.name.clone()?,
+ fence: Fence {
+ uid: pod.metadata.uid.clone()?,
+ resource_version: pod.metadata.resource_version.clone()?,
+ },
+ deletion_marked: pod.metadata.deletion_timestamp.is_some(),
+ startup,
+ recorded_intent: pod
+ .metadata
+ .annotations
+ .as_ref()
+ .and_then(|a| a.get(ANNOTATION_CREATE_INTENT))
+ .map(|v| Fingerprint::from_annotation(v)),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use k8s_openapi::api::core::v1::{
+ ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting,
+ ContainerStatus, PodStatus,
+ };
+ use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time};
+ use std::collections::BTreeMap;
+
+ fn identity() -> AgentIdentity {
+ use nostr::nips::nip19::ToBech32;
+ let keys = nostr::Keys::generate();
+ AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
+ }
+
+ fn base_pod(id: &AgentIdentity) -> Pod {
+ Pod {
+ metadata: ObjectMeta {
+ name: Some(id.pod_name()),
+ uid: Some("uid-1".into()),
+ resource_version: Some("100".into()),
+ labels: Some(id.labels()),
+ annotations: Some(
+ [(
+ ANNOTATION_PUBKEY_FULL.to_string(),
+ id.pubkey_hex().to_string(),
+ )]
+ .into_iter()
+ .collect::>(),
+ ),
+ ..Default::default()
+ },
+ ..Default::default()
+ }
+ }
+
+ fn with_container_state(mut pod: Pod, state: ContainerState) -> Pod {
+ pod.status = Some(PodStatus {
+ phase: Some("Running".into()),
+ container_statuses: Some(vec![ContainerStatus {
+ name: CONTAINER_NAME.into(),
+ state: Some(state),
+ ..Default::default()
+ }]),
+ ..Default::default()
+ });
+ pod
+ }
+
+ fn waiting(reason: &str, message: &str) -> ContainerState {
+ ContainerState {
+ waiting: Some(ContainerStateWaiting {
+ reason: Some(reason.into()),
+ message: Some(message.into()),
+ }),
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn running_container_is_started() {
+ let id = identity();
+ let pod = with_container_state(
+ base_pod(&id),
+ ContainerState {
+ running: Some(ContainerStateRunning::default()),
+ ..Default::default()
+ },
+ );
+ assert_eq!(
+ decode_startup(&pod),
+ StartupObservation::Resolved(Startup::Started)
+ );
+ }
+
+ /// Pod phase is not the criterion. A pod in phase `Running` whose
+ /// container never started must NOT read as started, or the reconciler
+ /// no-ops on a pod that will never serve.
+ #[test]
+ fn phase_running_with_waiting_container_is_not_started() {
+ let id = identity();
+ let pod = with_container_state(base_pod(&id), waiting("ContainerCreating", ""));
+ assert_eq!(
+ decode_startup(&pod),
+ StartupObservation::Resolved(Startup::NeverStartedRecoverable)
+ );
+ }
+
+ #[test]
+ fn terminated_container_is_terminated() {
+ let id = identity();
+ let pod = with_container_state(
+ base_pod(&id),
+ ContainerState {
+ terminated: Some(ContainerStateTerminated {
+ exit_code: 0,
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ );
+ assert_eq!(
+ decode_startup(&pod),
+ StartupObservation::Resolved(Startup::Terminated)
+ );
+ }
+
+ /// A terminal phase with no container status (evicted before the kubelet
+ /// reported) is still terminated — otherwise the residue is never GC'd.
+ #[test]
+ fn terminal_phase_without_container_status_is_terminated() {
+ let id = identity();
+ for phase in ["Succeeded", "Failed"] {
+ let mut pod = base_pod(&id);
+ pod.status = Some(PodStatus {
+ phase: Some(phase.into()),
+ ..Default::default()
+ });
+ assert_eq!(
+ decode_startup(&pod),
+ StartupObservation::Resolved(Startup::Terminated),
+ "phase {phase}"
+ );
+ }
+ }
+
+ #[test]
+ fn invalid_image_name_is_provably_broken() {
+ let id = identity();
+ let pod = with_container_state(base_pod(&id), waiting("InvalidImageName", "bad ref"));
+ assert_eq!(
+ decode_startup(&pod),
+ StartupObservation::Resolved(Startup::NeverStartedProvablyBroken)
+ );
+ }
+
+ /// Permanent pull failures are recognized from the message; anything
+ /// unrecognized stays on the ordinary observational path rather than
+ /// being guessed at.
+ #[test]
+ fn permanent_pull_failures_are_classified() {
+ let id = identity();
+ let cases = [
+ ("401 Unauthorized", PullFailure::Unauthorized),
+ ("pull access denied", PullFailure::Unauthorized),
+ ("manifest unknown", PullFailure::ManifestUnknown),
+ (
+ "no match for platform in manifest",
+ PullFailure::ArchMismatch,
+ ),
+ ];
+ for (message, expected) in cases {
+ let pod = with_container_state(base_pod(&id), waiting("ErrImagePull", message));
+ assert_eq!(
+ decode_startup(&pod),
+ StartupObservation::Resolved(Startup::NeverStartedPullFailing(expected)),
+ "message {message:?}"
+ );
+ }
+
+ let pod = with_container_state(
+ base_pod(&id),
+ waiting("ImagePullBackOff", "dial tcp: i/o timeout"),
+ );
+ assert_eq!(
+ decode_startup(&pod),
+ StartupObservation::Resolved(Startup::NeverStartedRecoverable),
+ "a transient network failure must not be reported as permanent"
+ );
+ }
+
+ /// The kubelet's reason string is a hint, not proof: a config error defers
+ /// to a most-recent Secret read before anything is called broken.
+ #[test]
+ fn config_error_defers_to_a_secret_read() {
+ let id = identity();
+ let mut pod =
+ with_container_state(base_pod(&id), waiting("CreateContainerConfigError", ""));
+ pod.spec = Some(k8s_openapi::api::core::v1::PodSpec {
+ containers: vec![k8s_openapi::api::core::v1::Container {
+ name: CONTAINER_NAME.into(),
+ env_from: Some(vec![k8s_openapi::api::core::v1::EnvFromSource {
+ secret_ref: Some(k8s_openapi::api::core::v1::SecretEnvSource {
+ name: "buzz-agent-abc-gen1".into(),
+ optional: Some(false),
+ }),
+ ..Default::default()
+ }]),
+ ..Default::default()
+ }],
+ ..Default::default()
+ });
+ assert_eq!(
+ decode_startup(&pod),
+ StartupObservation::ConfigErrorPendingSecretCheck {
+ secret_name: "buzz-agent-abc-gen1".into()
+ }
+ );
+ }
+
+ /// The auto-repair fence: an object that matches our schema but lacks the
+ /// marker, or carries someone else's pubkey, is never verified — so it can
+ /// never be no-op'd against, deleted, or returned as an `agent_id`.
+ #[test]
+ fn unmarked_or_mismatched_objects_fail_verification() {
+ let id = identity();
+ let other = identity();
+
+ let mut unmarked = base_pod(&id);
+ unmarked.metadata.labels = Some(BTreeMap::new());
+ assert!(
+ verify(&unmarked, &id, Startup::Started).is_none(),
+ "unmarked pod verified"
+ );
+
+ let mut wrong_version = base_pod(&id);
+ let mut labels = id.labels();
+ labels.insert(LABEL_BINDING_VERSION.to_string(), "999".to_string());
+ wrong_version.metadata.labels = Some(labels);
+ assert!(verify(&wrong_version, &id, Startup::Started).is_none());
+
+ let mut mismatched = base_pod(&id);
+ mismatched.metadata.annotations = Some(
+ [(
+ ANNOTATION_PUBKEY_FULL.to_string(),
+ other.pubkey_hex().to_string(),
+ )]
+ .into_iter()
+ .collect(),
+ );
+ assert!(
+ verify(&mismatched, &id, Startup::Started).is_none(),
+ "collision verified"
+ );
+
+ let mut missing = base_pod(&id);
+ missing.metadata.annotations = Some(BTreeMap::new());
+ assert!(verify(&missing, &id, Startup::Started).is_none());
+
+ assert!(
+ verify(&base_pod(&id), &id, Startup::Started).is_some(),
+ "own pod rejected"
+ );
+ }
+
+ /// The fence must come from the observed object, and the deletion mark
+ /// must be read even though the phase says `Running`.
+ #[test]
+ fn verified_pod_carries_the_fence_and_deletion_mark() {
+ let id = identity();
+ let mut pod = base_pod(&id);
+ pod.metadata.deletion_timestamp = Some(Time(chrono::Utc::now()));
+ let verified = verify(&pod, &id, Startup::Started).unwrap();
+ assert_eq!(verified.fence.uid, "uid-1");
+ assert_eq!(verified.fence.resource_version, "100");
+ assert!(verified.deletion_marked);
+ }
+
+ /// A pod with no recorded intent reads as `None`, which the classifier
+ /// groups with divergence.
+ #[test]
+ fn missing_intent_annotation_decodes_as_none() {
+ let id = identity();
+ assert!(verify(&base_pod(&id), &id, Startup::Started)
+ .unwrap()
+ .recorded_intent
+ .is_none());
+ }
+
+ /// A pull-failure report must name the registry and the immutable
+ /// reference and nothing else — never the kubelet's raw message, which
+ /// can echo a registry token.
+ #[test]
+ fn pull_failure_messages_are_actionable_and_redacted() {
+ let image = format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64));
+ for failure in [
+ PullFailure::Unauthorized,
+ PullFailure::ManifestUnknown,
+ PullFailure::ArchMismatch,
+ ] {
+ let msg = pull_failure_message(failure, &image);
+ assert!(msg.contains("ghcr.io"), "{msg}");
+ assert!(msg.contains(&image), "{msg}");
+ for secret in ["Bearer", "password", "nsec1", "token"] {
+ assert!(!msg.contains(secret), "leaked {secret}: {msg}");
+ }
+ }
+ }
+
+ #[test]
+ fn secret_ownership_requires_marker_and_annotation() {
+ let id = identity();
+ let other = identity();
+ let ours = Secret {
+ metadata: ObjectMeta {
+ labels: Some(id.labels()),
+ annotations: Some(
+ [(
+ ANNOTATION_PUBKEY_FULL.to_string(),
+ id.pubkey_hex().to_string(),
+ )]
+ .into(),
+ ),
+ ..Default::default()
+ },
+ ..Default::default()
+ };
+ assert!(secret_is_ours(&ours, &id));
+ assert!(!secret_is_ours(&ours, &other));
+
+ let mut unmarked = ours.clone();
+ unmarked.metadata.labels = Some(BTreeMap::new());
+ assert!(!secret_is_ours(&unmarked, &id));
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/pod.rs b/crates/buzz-backend-kubernetes/src/pod.rs
new file mode 100644
index 0000000000..725f98fc7d
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/pod.rs
@@ -0,0 +1,446 @@
+//! Pod and Secret construction (spec §Pod shape, §K8s Secrets).
+//!
+//! The builder is pure: it turns resolved inputs into API objects and performs
+//! no I/O, so every normative field is a unit assertion.
+
+use crate::config::{
+ ProviderConfig, RESTART_POLICY, RUN_AS_GID, RUN_AS_UID, TERMINATION_GRACE_SECONDS,
+ WORKSPACE_PATH,
+};
+use crate::intent::{Fingerprint, IntentTemplate};
+use crate::naming::{
+ AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_IMAGE, ANNOTATION_PUBKEY_FULL,
+};
+use k8s_openapi::api::core::v1::{
+ Capabilities, Container, EmptyDirVolumeSource, EnvFromSource, Pod, PodSecurityContext, PodSpec,
+ ResourceRequirements, SeccompProfile, Secret, SecretEnvSource, SecurityContext, Volume,
+ VolumeMount,
+};
+use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
+use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
+use std::collections::BTreeMap;
+
+/// Volume name for the agent's writable workspace.
+const WORKSPACE_VOLUME: &str = "workspace";
+
+/// The container name. Fixed: log and exec tooling addresses it by name.
+const CONTAINER_NAME: &str = "agent";
+
+/// Build the per-attempt Secret holding the resolved environment.
+///
+/// `immutable: true` — the Secret is written once per attempt and never
+/// updated, which is what lets the pod's `envFrom` reference be treated as an
+/// atomic binding to this exact payload (§K8s Secrets).
+pub fn build_secret(
+ identity: &AgentIdentity,
+ namespace: &str,
+ generation: &str,
+ env: BTreeMap,
+) -> Secret {
+ Secret {
+ metadata: ObjectMeta {
+ name: Some(identity.secret_name(generation)),
+ namespace: Some(namespace.to_string()),
+ labels: Some(identity.labels()),
+ annotations: Some(
+ [(
+ ANNOTATION_PUBKEY_FULL.to_string(),
+ identity.pubkey_hex().to_string(),
+ )]
+ .into_iter()
+ .collect(),
+ ),
+ ..Default::default()
+ },
+ string_data: Some(env),
+ immutable: Some(true),
+ ..Default::default()
+ }
+}
+
+/// Build the pod for one create attempt.
+///
+/// The `fingerprint` is computed from [`intent_template`] over a type that
+/// cannot contain the generation or any Secret value, so it is stable across
+/// attempts of the same configuration.
+pub fn build_pod(
+ identity: &AgentIdentity,
+ cfg: &ProviderConfig,
+ generation: &str,
+ fingerprint: &Fingerprint,
+) -> Pod {
+ let annotations: BTreeMap = [
+ (
+ ANNOTATION_PUBKEY_FULL.to_string(),
+ identity.pubkey_hex().to_string(),
+ ),
+ (
+ ANNOTATION_CREATE_INTENT.to_string(),
+ fingerprint.as_str().to_string(),
+ ),
+ (ANNOTATION_IMAGE.to_string(), cfg.image.as_str().to_string()),
+ ]
+ .into_iter()
+ .collect();
+
+ let requests: BTreeMap = [
+ (
+ "cpu".to_string(),
+ Quantity(cfg.resources.cpu_request.clone()),
+ ),
+ (
+ "memory".to_string(),
+ Quantity(cfg.resources.memory_request.clone()),
+ ),
+ ]
+ .into_iter()
+ .collect();
+ let limits: BTreeMap = [
+ ("cpu".to_string(), Quantity(cfg.resources.cpu_limit.clone())),
+ (
+ "memory".to_string(),
+ Quantity(cfg.resources.memory_limit.clone()),
+ ),
+ ]
+ .into_iter()
+ .collect();
+
+ let container = Container {
+ name: CONTAINER_NAME.to_string(),
+ image: Some(cfg.image.as_str().to_string()),
+ // No `command`/`args`: the image's entrypoint execs the harness as
+ // PID 1 (§Entrypoint). Overriding it here would be how a provider
+ // accidentally puts a shell in front of the signal receiver.
+ env_from: Some(vec![EnvFromSource {
+ secret_ref: Some(SecretEnvSource {
+ name: identity.secret_name(generation),
+ optional: Some(false),
+ }),
+ ..Default::default()
+ }]),
+ resources: Some(ResourceRequirements {
+ requests: Some(requests),
+ limits: Some(limits),
+ ..Default::default()
+ }),
+ volume_mounts: Some(vec![VolumeMount {
+ name: WORKSPACE_VOLUME.to_string(),
+ mount_path: WORKSPACE_PATH.to_string(),
+ ..Default::default()
+ }]),
+ security_context: Some(SecurityContext {
+ allow_privilege_escalation: Some(false),
+ capabilities: Some(Capabilities {
+ drop: Some(vec!["ALL".to_string()]),
+ ..Default::default()
+ }),
+ // `readOnlyRootFilesystem` is deliberately unset: the sprig
+ // toolchain writes outside the workspace mount (§Pod shape).
+ ..Default::default()
+ }),
+ ..Default::default()
+ };
+
+ Pod {
+ metadata: ObjectMeta {
+ name: Some(identity.pod_name()),
+ namespace: Some(cfg.namespace.clone()),
+ labels: Some(identity.labels()),
+ annotations: Some(annotations),
+ ..Default::default()
+ },
+ spec: Some(PodSpec {
+ containers: vec![container],
+ restart_policy: Some(RESTART_POLICY.to_string()),
+ termination_grace_period_seconds: Some(TERMINATION_GRACE_SECONDS),
+ // The agent runs prompted, untrusted code while holding an nsec;
+ // an ambient ServiceAccount token would be an API-stealable
+ // credential it never needs (§Pod shape hardening). Naming a
+ // service account selects a scheduling/RBAC identity and MUST NOT
+ // re-enable token mounting.
+ automount_service_account_token: Some(false),
+ service_account_name: cfg.service_account.clone(),
+ security_context: Some(PodSecurityContext {
+ run_as_non_root: Some(true),
+ run_as_user: Some(RUN_AS_UID),
+ run_as_group: Some(RUN_AS_GID),
+ fs_group: Some(RUN_AS_GID),
+ seccomp_profile: Some(SeccompProfile {
+ type_: "RuntimeDefault".to_string(),
+ ..Default::default()
+ }),
+ ..Default::default()
+ }),
+ volumes: Some(vec![Volume {
+ name: WORKSPACE_VOLUME.to_string(),
+ empty_dir: Some(EmptyDirVolumeSource::default()),
+ ..Default::default()
+ }]),
+ ..Default::default()
+ }),
+ ..Default::default()
+ }
+}
+
+/// The create-intent template for this configuration (§Deploy State Machine).
+pub fn intent_template(
+ cfg: &ProviderConfig,
+ env_keys: impl IntoIterator- ,
+) -> IntentTemplate {
+ IntentTemplate::new(
+ &cfg.namespace,
+ &cfg.image,
+ &cfg.resources,
+ cfg.service_account.as_deref(),
+ env_keys,
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config;
+
+ fn identity() -> AgentIdentity {
+ use nostr::nips::nip19::ToBech32;
+ let keys = nostr::Keys::generate();
+ AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
+ }
+
+ fn provider_config() -> ProviderConfig {
+ config::parse(&serde_json::json!({
+ "namespace": "buzz-agents-test",
+ "image": format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)),
+ }))
+ .unwrap()
+ }
+
+ fn pod() -> Pod {
+ let cfg = provider_config();
+ build_pod(
+ &identity(),
+ &cfg,
+ "gen00001",
+ &intent_template(&cfg, ["BUZZ_RELAY_URL".to_string()]).fingerprint(),
+ )
+ }
+
+ fn spec(pod: &Pod) -> &PodSpec {
+ pod.spec.as_ref().unwrap()
+ }
+
+ /// Every hardening default from §Pod shape, asserted individually so a
+ /// dropped one names itself.
+ #[test]
+ fn hardening_defaults_are_all_present() {
+ let pod = pod();
+ let spec = spec(&pod);
+ assert_eq!(spec.automount_service_account_token, Some(false));
+
+ let sc = spec
+ .security_context
+ .as_ref()
+ .expect("pod security context");
+ assert_eq!(sc.run_as_non_root, Some(true));
+ assert_eq!(sc.run_as_user, Some(RUN_AS_UID));
+ assert_ne!(sc.run_as_user, Some(0), "root UID");
+ assert_eq!(sc.run_as_group, Some(RUN_AS_GID));
+ assert_eq!(
+ sc.seccomp_profile.as_ref().map(|p| p.type_.as_str()),
+ Some("RuntimeDefault")
+ );
+
+ let csc = spec.containers[0]
+ .security_context
+ .as_ref()
+ .expect("container sc");
+ assert_eq!(csc.allow_privilege_escalation, Some(false));
+ assert_eq!(
+ csc.capabilities.as_ref().and_then(|c| c.drop.clone()),
+ Some(vec!["ALL".to_string()])
+ );
+ assert_ne!(csc.privileged, Some(true));
+ }
+
+ /// The forbidden host-namespace and hostPath escapes, asserted as absence.
+ #[test]
+ fn never_uses_host_namespaces_or_host_paths() {
+ let pod = pod();
+ let spec = spec(&pod);
+ assert!(spec.host_pid.is_none() || spec.host_pid == Some(false));
+ assert!(spec.host_network.is_none() || spec.host_network == Some(false));
+ assert!(spec.host_ipc.is_none() || spec.host_ipc == Some(false));
+ for volume in spec.volumes.as_ref().unwrap() {
+ assert!(
+ volume.host_path.is_none(),
+ "hostPath volume {}",
+ volume.name
+ );
+ }
+ }
+
+ /// `Never` only. `OnFailure` is gated on the harness exit-code contract
+ /// *and* a crash-loop classification row (`:1121-1139`); the config layer
+ /// refuses `inactivity_seconds: 0` so this arm is unreachable, and the
+ /// assertion keeps it that way.
+ #[test]
+ fn restart_policy_is_never() {
+ assert_eq!(spec(&pod()).restart_policy.as_deref(), Some("Never"));
+ }
+
+ /// 60s, not Kubernetes' default 30s — which would SIGKILL the harness
+ /// mid-drain and leave presence stale-online (§Pod shape).
+ #[test]
+ fn declares_the_sixty_second_grace_budget() {
+ assert_eq!(spec(&pod()).termination_grace_period_seconds, Some(60));
+ }
+
+ /// The pod must not override the image's entrypoint: the image execs the
+ /// harness as PID 1, and a `command` here is how a shell ends up in front
+ /// of the signal receiver (§Entrypoint).
+ #[test]
+ fn does_not_override_the_image_entrypoint() {
+ let pod = pod();
+ let container = &spec(&pod).containers[0];
+ assert!(container.command.is_none(), "overrode the entrypoint");
+ assert!(container.args.is_none());
+ }
+
+ #[test]
+ fn workspace_is_an_emptydir_mounted_at_home() {
+ let pod = pod();
+ let spec = spec(&pod);
+ let volume = &spec.volumes.as_ref().unwrap()[0];
+ assert!(volume.empty_dir.is_some());
+ assert!(volume.persistent_volume_claim.is_none());
+ let mount = &spec.containers[0].volume_mounts.as_ref().unwrap()[0];
+ assert_eq!(mount.name, volume.name);
+ assert_eq!(mount.mount_path, WORKSPACE_PATH);
+ }
+
+ #[test]
+ fn resources_carry_the_configured_requests_and_limits() {
+ let mut cfg = provider_config();
+ cfg.resources.cpu_limit = "4".into();
+ let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f"));
+ let r = spec(&pod).containers[0].resources.as_ref().unwrap();
+ assert_eq!(r.requests.as_ref().unwrap()["cpu"], Quantity("1".into()));
+ assert_eq!(
+ r.requests.as_ref().unwrap()["memory"],
+ Quantity("2Gi".into())
+ );
+ assert_eq!(r.limits.as_ref().unwrap()["cpu"], Quantity("4".into()));
+ assert_eq!(r.limits.as_ref().unwrap()["memory"], Quantity("4Gi".into()));
+ }
+
+ /// `envFrom` must point at this attempt's Secret and must NOT be optional:
+ /// an optional reference starts the container with no identity at all,
+ /// turning a missing-Secret bug into an agent that silently cannot
+ /// authenticate.
+ #[test]
+ fn env_from_references_this_attempts_secret_and_is_required() {
+ let id = identity();
+ let cfg = provider_config();
+ let pod = build_pod(&id, &cfg, "gen00042", &Fingerprint::from_annotation("f"));
+ let source = &spec(&pod).containers[0].env_from.as_ref().unwrap()[0];
+ let secret_ref = source.secret_ref.as_ref().unwrap();
+ assert_eq!(secret_ref.name, id.secret_name("gen00042"));
+ assert_eq!(secret_ref.optional, Some(false));
+ assert!(source.config_map_ref.is_none());
+ }
+
+ /// Identity, ownership marker, and the recorded intent all travel on the
+ /// pod — the GC and reconciliation fences read exactly these.
+ #[test]
+ fn pod_carries_identity_marker_and_recorded_intent() {
+ let id = identity();
+ let cfg = provider_config();
+ let fp = intent_template(&cfg, ["A".to_string()]).fingerprint();
+ let pod = build_pod(&id, &cfg, "g", &fp);
+ let meta = &pod.metadata;
+ assert_eq!(meta.name.as_deref(), Some(id.pod_name().as_str()));
+ assert_eq!(meta.namespace.as_deref(), Some("buzz-agents-test"));
+ assert_eq!(meta.labels.as_ref().unwrap(), &id.labels());
+ let ann = meta.annotations.as_ref().unwrap();
+ assert_eq!(ann[ANNOTATION_PUBKEY_FULL], id.pubkey_hex());
+ assert_eq!(ann[ANNOTATION_CREATE_INTENT], fp.as_str());
+ assert_eq!(ann[ANNOTATION_IMAGE], cfg.image.as_str());
+ }
+
+ /// The Secret is immutable and marker-bearing: immutability is what makes
+ /// the pod's `envFrom` an atomic binding, and the marker is what GC
+ /// requires before it will delete anything.
+ #[test]
+ fn secret_is_immutable_marked_and_holds_the_env() {
+ let id = identity();
+ let env: BTreeMap
=
+ [("BUZZ_RELAY_URL".to_string(), "wss://r".to_string())].into();
+ let secret = build_secret(&id, "ns", "gen1", env.clone());
+ assert_eq!(secret.immutable, Some(true));
+ assert_eq!(secret.string_data.as_ref().unwrap(), &env);
+ assert_eq!(
+ secret.metadata.name.as_deref(),
+ Some(id.secret_name("gen1").as_str())
+ );
+ assert_eq!(secret.metadata.labels.as_ref().unwrap(), &id.labels());
+ assert_eq!(
+ secret.metadata.annotations.as_ref().unwrap()[ANNOTATION_PUBKEY_FULL],
+ id.pubkey_hex()
+ );
+ // `data` must stay unset — setting both is an apiserver rejection.
+ assert!(secret.data.is_none());
+ }
+
+ /// Naming a service account selects a scheduling identity; it must not
+ /// re-enable token mounting (§Pod shape hardening, `:1221-1225`).
+ #[test]
+ fn service_account_does_not_re_enable_token_mounting() {
+ let mut cfg = provider_config();
+ cfg.service_account = Some("agent-sa".into());
+ let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f"));
+ assert_eq!(spec(&pod).service_account_name.as_deref(), Some("agent-sa"));
+ assert_eq!(spec(&pod).automount_service_account_token, Some(false));
+ }
+
+ /// The fingerprint recorded on the pod is the one the classifier will
+ /// recompute — pinned end-to-end so a builder change that forgets to feed
+ /// the template a field cannot pass silently.
+ #[test]
+ fn recorded_fingerprint_matches_a_fresh_computation() {
+ let cfg = provider_config();
+ let keys = ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()];
+ let fp = intent_template(&cfg, keys.clone()).fingerprint();
+ let pod = build_pod(&identity(), &cfg, "gen-a", &fp);
+ let recorded = Fingerprint::from_annotation(
+ &pod.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT],
+ );
+ assert_eq!(recorded, intent_template(&cfg, keys).fingerprint());
+ }
+
+ /// Two attempts differing only in generation must record the *same*
+ /// fingerprint, or the divergence discriminator fires on every deploy and
+ /// the never-started row deletes healthy pending pods.
+ #[test]
+ fn generation_does_not_change_the_recorded_fingerprint() {
+ let cfg = provider_config();
+ let keys = ["BUZZ_RELAY_URL".to_string()];
+ let a = intent_template(&cfg, keys.clone()).fingerprint();
+ let b = intent_template(&cfg, keys).fingerprint();
+ let id = identity();
+ let pod_a = build_pod(&id, &cfg, "gen-1", &a);
+ let pod_b = build_pod(&id, &cfg, "gen-2", &b);
+ let read =
+ |p: &Pod| p.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT].clone();
+ assert_eq!(read(&pod_a), read(&pod_b));
+ // ...while the Secret they reference differs.
+ let secret_of = |p: &Pod| {
+ spec(p).containers[0].env_from.as_ref().unwrap()[0]
+ .secret_ref
+ .as_ref()
+ .unwrap()
+ .name
+ .clone()
+ };
+ assert_ne!(secret_of(&pod_a), secret_of(&pod_b));
+ }
+}
diff --git a/crates/buzz-backend-kubernetes/src/reconcile.rs b/crates/buzz-backend-kubernetes/src/reconcile.rs
new file mode 100644
index 0000000000..df2f99789f
--- /dev/null
+++ b/crates/buzz-backend-kubernetes/src/reconcile.rs
@@ -0,0 +1,1585 @@
+//! The deploy loop: executes [`crate::classify`]'s actions against a substrate
+//! and re-enters (spec §Deploy State Machine).
+//!
+//! The substrate is a trait so the conformance tests drive this exact
+//! reconciler — the shipped code path, not a test-only reimplementation — with
+//! a fake cluster and a fake clock.
+//!
+//! Two shapes are worth naming up front, because they are what keep the loop
+//! terminating:
+//!
+//! * **Success means the harness container started** (`:696-699`). There is no
+//! "deployed but not confirmed" success: `deploy` returns an `agent_id` or an
+//! in-band error carrying the latest condition. The wire has no third form.
+//! * **A create-conflict loser never repairs.** It verifies the winner, drops
+//! its own Secret, and *observes* until the winner starts. Applying the
+//! divergence row to the pod that just beat it is exactly the ping-pong the
+//! spec forbids (`:845-850`), and the escape it names is the *next* deploy,
+//! not this one.
+
+use crate::classify::{self, Action, Fence, Startup, VerifiedPod};
+use crate::config::ProviderConfig;
+use crate::gc;
+use crate::naming::AgentIdentity;
+use crate::observe::{self, StartupObservation};
+use chrono::{DateTime, Utc};
+use k8s_openapi::api::core::v1::{Pod, Secret};
+use std::collections::BTreeMap;
+use std::time::Duration;
+
+/// Outcome of a create against the deterministic pod name.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CreateOutcome {
+ Created,
+ /// 409 with `Status.reason: AlreadyExists` — a concurrent attempt won.
+ /// Discriminated on the typed reason, never on the HTTP code alone: 409 is
+ /// also `Conflict`, which means a failed precondition (`:780-794`).
+ AlreadyExists,
+}
+
+/// Outcome of a fenced delete.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DeleteOutcome {
+ Accepted,
+ /// Already gone. Delete-not-found is success (`:842`).
+ NotFound,
+ /// 409 with `Status.reason: Conflict` — the object changed since the
+ /// observation that authorized this delete. Neither an error nor
+ /// permission to retry: re-enter and classify what exists now
+ /// (`:775-777`).
+ PreconditionFailed,
+}
+
+/// The cluster operations the reconciler needs. Everything here is I/O;
+/// everything that decides is pure and lives in `classify`/`gc`.
+#[allow(async_fn_in_trait)]
+pub trait Substrate {
+ /// Create the namespace if absent. On RBAC denial the error MUST name the
+ /// literal `kubectl create namespace ` command and MUST NOT fall
+ /// back to `default` (`:1002-1005`).
+ async fn ensure_namespace(&self, namespace: &str) -> Result<(), String>;
+
+ /// Most-recent read of the pods matching this identity's selector, plus
+ /// the apiserver's clock from the same call's HTTP `Date` header. `None`
+ /// clock means the header was absent or unparseable, which makes the
+ /// orphan-Secret sweep skip (`:1321-1335`).
+ async fn list_pods(&self, selector: &str) -> Result<(Vec, Option>), String>;
+
+ async fn list_secrets(&self, selector: &str) -> Result, String>;
+
+ /// Most-recent existence check (`resourceVersion` explicitly unset, not
+ /// `"0"`): the classifier treats a confirmed absence as proof, so a
+ /// possibly-stale cache read would be proof of nothing (`:761-769`).
+ async fn secret_exists(&self, name: &str) -> Result;
+
+ async fn create_secret(&self, secret: &Secret) -> Result<(), String>;
+
+ async fn create_pod(&self, pod: &Pod) -> Result;
+
+ /// Compare-and-delete against the fence from the authorizing observation.
+ /// Uses the object's own grace period — never `grace_period_seconds: 0`,
+ /// which is a force-kill that discards the declared 60s shutdown budget
+ /// (`:1185-1189`).
+ async fn delete_pod(&self, name: &str, fence: &Fence) -> Result;
+
+ /// Best-effort: the Secret may already be gone, which is success.
+ async fn delete_secret(&self, name: &str) -> Result<(), String>;
+
+ /// Read one pod by name, most-recent. `None` is a confirmed absence.
+ async fn get_pod(&self, name: &str) -> Result, String>;
+
+ async fn sleep(&self, duration: Duration);
+
+ /// Monotonic elapsed time since the operation began. Fake-clock driven in
+ /// tests; the deadline must not depend on wall-clock adjustments.
+ fn elapsed(&self) -> Duration;
+}
+
+/// Interval between reconciler polls. Short enough that a fast start is
+/// reported promptly, long enough not to hammer the apiserver for 600s.
+const POLL_INTERVAL: Duration = Duration::from_secs(2);
+
+/// The deploy operation deadline (spec §Deploy: `timeout: 600s`).
+const DEADLINE: Duration = Duration::from_secs(gc::OPERATION_DEADLINE_SECS as u64);
+
+/// Settle a pod's startup state, performing the one most-recent Secret read
+/// that `CreateContainerConfigError` requires.
+async fn settle(substrate: &impl Substrate, pod: &Pod) -> Result {
+ Ok(match observe::decode_startup(pod) {
+ StartupObservation::Resolved(startup) => startup,
+ StartupObservation::ConfigErrorPendingSecretCheck { secret_name } => {
+ // A confirmed absence is proof; anything else stays recoverable,
+ // because the kubelet's reason string alone is not evidence.
+ if substrate.secret_exists(&secret_name).await? {
+ Startup::NeverStartedRecoverable
+ } else {
+ Startup::NeverStartedProvablyBroken
+ }
+ }
+ })
+}
+
+/// Observe the single pod owned by this identity, verified.
+///
+/// `Ok(None)` conflates "absent" with "present but not ours" on purpose *here*
+/// — both mean the classifier has nothing it may act on. The create path
+/// separates them, because only there is the difference actionable.
+async fn observe_pod(
+ substrate: &impl Substrate,
+ identity: &AgentIdentity,
+) -> Result, String> {
+ let Some(pod) = substrate.get_pod(&identity.pod_name()).await? else {
+ return Ok(None);
+ };
+ let startup = settle(substrate, &pod).await?;
+ Ok(observe::verify(&pod, identity, startup))
+}
+
+/// The latest condition to report, read fresh at the moment of reporting.
+///
+/// Reading it here rather than threading it through every loop iteration is
+/// what "the *latest* redacted condition" (`:689`) asks for, and it costs a
+/// read only on the paths that are already failing.
+async fn latest_condition(substrate: &impl Substrate, identity: &AgentIdentity) -> String {
+ match substrate.get_pod(&identity.pod_name()).await {
+ Ok(Some(pod)) => observe::condition(&pod)
+ .unwrap_or_else(|| "no condition reported by the cluster".to_string()),
+ Ok(None) => "the pod no longer exists".to_string(),
+ Err(e) => format!("the pod's condition could not be read: {e}"),
+ }
+}
+
+/// Preflight GC (§K8s GC). Failures are logged and swallowed: GC is hygiene,
+/// and a deploy must not fail because a stale object could not be listed or
+/// removed. A denial that actually blocks this deploy resurfaces at create,
+/// where the message names the operation the user was denied.
+async fn preflight_gc(substrate: &impl Substrate, identity: &AgentIdentity) {
+ if let Err(e) = try_preflight_gc(substrate, identity).await {
+ eprintln!("gc: preflight pass skipped: {e}");
+ }
+}
+
+async fn try_preflight_gc(
+ substrate: &impl Substrate,
+ identity: &AgentIdentity,
+) -> Result<(), String> {
+ let selector = identity.selector();
+ let (pods, server_now) = substrate.list_pods(&selector).await?;
+ let secrets = substrate.list_secrets(&selector).await?;
+
+ let mut terminated: Vec = Vec::new();
+ for pod in &pods {
+ if matches!(settle(substrate, pod).await?, Startup::Terminated) {
+ if let Some(name) = pod.metadata.name.clone() {
+ terminated.push(name);
+ }
+ }
+ }
+
+ let plan = gc::plan(
+ identity,
+ &pods,
+ &secrets,
+ |pod| {
+ pod.metadata
+ .name
+ .as_deref()
+ .map(|n| terminated.iter().any(|t| t == n))
+ .unwrap_or(false)
+ },
+ server_now,
+ );
+
+ for name in &plan.pods {
+ // Re-read to fence the delete against the object we just observed; a
+ // pod that changed since the list is simply skipped this pass.
+ let Some(pod) = substrate.get_pod(name).await? else {
+ continue;
+ };
+ let (Some(uid), Some(rv)) = (
+ pod.metadata.uid.clone(),
+ pod.metadata.resource_version.clone(),
+ ) else {
+ continue;
+ };
+ if !matches!(settle(substrate, &pod).await?, Startup::Terminated) {
+ continue;
+ }
+ let fence = Fence {
+ uid,
+ resource_version: rv,
+ };
+ if let Err(e) = substrate.delete_pod(name, &fence).await {
+ eprintln!("gc: could not delete terminated pod {name}: {e}");
+ }
+ }
+ for name in &plan.secrets {
+ if let Err(e) = substrate.delete_secret(name).await {
+ eprintln!("gc: could not delete secret {name}: {e}");
+ }
+ }
+ Ok(())
+}
+
+/// Wait for a pod to actually disappear.
+///
+/// Mandatory before recreating: `DELETE` returns success while the object
+/// still exists, and the deterministic name stays taken for the whole grace
+/// period (`:1177-1195`).
+async fn await_disappearance(substrate: &impl Substrate, name: &str) -> Result<(), String> {
+ while substrate.elapsed() < DEADLINE {
+ if substrate.get_pod(name).await?.is_none() {
+ return Ok(());
+ }
+ substrate.sleep(POLL_INTERVAL).await;
+ }
+ Err(format!(
+ "timed out after {}s waiting for {name} to finish terminating",
+ DEADLINE.as_secs()
+ ))
+}
+
+/// Is `secret` referenced by any pod that currently exists under this
+/// identity's selector?
+///
+/// Protection deliberately spans *all* our pods, not just the winner: an
+/// `envFrom` reference from a pod still pulling its image is exactly as
+/// load-bearing as one from a running pod (`:1261-1264`).
+async fn secret_is_referenced(
+ substrate: &impl Substrate,
+ identity: &AgentIdentity,
+ secret: &str,
+) -> Result {
+ let (pods, _) = substrate.list_pods(&identity.selector()).await?;
+ Ok(pods
+ .iter()
+ .filter_map(observe::referenced_secret)
+ .any(|name| name == secret))
+}
+
+/// Drop this attempt's own Secret once nothing references it.
+///
+/// Only ever called with a name this process generated, and gated on the
+/// reference check: "never the winner's, never any Secret referenced by an
+/// existing pod" (`:1259-1264`). Failure is logged, not fatal — a leaked
+/// Secret is collected by the age-gated sweep.
+async fn drop_own_secret(substrate: &impl Substrate, identity: &AgentIdentity, secret: &str) {
+ match secret_is_referenced(substrate, identity, secret).await {
+ Ok(false) => {
+ if let Err(e) = substrate.delete_secret(secret).await {
+ eprintln!("could not clean up own unreferenced secret {secret}: {e}");
+ }
+ }
+ Ok(true) => {}
+ Err(e) => eprintln!("could not check whether {secret} is still referenced: {e}"),
+ }
+}
+
+/// Lost the create race: adopt the elected winner.
+///
+/// Observe-only by construction — this function has no delete edge for the
+/// pod. A winner that is terminated or provably broken is reported, not
+/// repaired; the spec's escape is "a *subsequent* deploy that walks in and
+/// observes that never-started divergent winner replaces it normally"
+/// (`:849-850`).
+async fn adopt_winner(
+ substrate: &impl Substrate,
+ identity: &AgentIdentity,
+ own_secret: &str,
+) -> Result {
+ let name = identity.pod_name();
+
+ // Verify before adopting. A pod under our deterministic name that fails
+ // the marker/annotation check is not ours to adopt, wait for, or touch —
+ // and it will never become ours, so this is terminal rather than a retry.
+ match substrate.get_pod(&name).await? {
+ None => {}
+ Some(pod) => {
+ let startup = settle(substrate, &pod).await?;
+ if observe::verify(&pod, identity, startup).is_none() {
+ drop_own_secret(substrate, identity, own_secret).await;
+ return Err(format!(
+ "a pod named {name} already exists in this namespace but is not \
+ managed by this provider for this agent (it lacks the management \
+ marker or carries a different agent identity). Remove it, or \
+ deploy this agent to a different namespace."
+ ));
+ }
+ }
+ }
+
+ drop_own_secret(substrate, identity, own_secret).await;
+
+ // Then wait for the winner exactly as we would wait for our own pod:
+ // success still means the harness container started.
+ loop {
+ if substrate.elapsed() >= DEADLINE {
+ return Err(format!(
+ "startup not confirmed within {}s for {name} (another deploy of this \
+ agent created it): {}",
+ DEADLINE.as_secs(),
+ latest_condition(substrate, identity).await
+ ));
+ }
+ match observe_pod(substrate, identity).await? {
+ Some(pod) if matches!(pod.startup, Startup::Started) => return Ok(pod.name),
+ Some(pod) if pod.deletion_marked => {
+ return Err(format!(
+ "{name} was created by another deploy of this agent and is already \
+ being deleted; try again"
+ ))
+ }
+ Some(pod)
+ if matches!(
+ pod.startup,
+ Startup::Terminated | Startup::NeverStartedProvablyBroken
+ ) =>
+ {
+ return Err(format!(
+ "{name} was created by another deploy of this agent and did not \
+ start: {}",
+ latest_condition(substrate, identity).await
+ ))
+ }
+ // Gone again, or still coming up: keep observing under this
+ // operation's deadline.
+ _ => substrate.sleep(POLL_INTERVAL).await,
+ }
+ }
+}
+
+/// Run the deploy state machine to a terminal outcome: the started pod's name,
+/// or an in-band error carrying the latest condition.
+pub async fn deploy(
+ substrate: &impl Substrate,
+ identity: &AgentIdentity,
+ cfg: &ProviderConfig,
+ env: BTreeMap,
+) -> Result {
+ substrate.ensure_namespace(&cfg.namespace).await?;
+ preflight_gc(substrate, identity).await;
+
+ let desired = crate::pod::intent_template(cfg, env.keys().cloned()).fingerprint();
+
+ // Has THIS call created a pod? Set once its create lands. The replacement
+ // rows below are for residue from a previous life; once this call has made
+ // its own attempt, a replace-classification means that attempt failed —
+ // and startup verification is part of create, so the failure is reported
+ // in-band rather than retried. Without this bound a deterministic startup
+ // failure (the harness starts, rejects its configuration, exits) is
+ // delete-recreated every poll for the whole deadline, minting an immutable
+ // Secret per cycle — measured live at 107 Secrets in one 600s call, every
+ // one younger than the orphan sweep's age gate.
+ let mut created_this_call = false;
+
+ loop {
+ if substrate.elapsed() >= DEADLINE {
+ return Err(format!(
+ "startup not confirmed within {}s for {}: {}",
+ DEADLINE.as_secs(),
+ identity.pod_name(),
+ latest_condition(substrate, identity).await
+ ));
+ }
+
+ let observed = observe_pod(substrate, identity).await?;
+ match classify::classify(observed.as_ref(), &desired) {
+ // The only success edge: the harness container is running.
+ Action::NoOp { agent_id } => return Ok(agent_id),
+
+ // Self-healing states. Never delete, on this call or any later one
+ // — what replaces a never-started pod is a config change, never a
+ // deadline (`:717-729`).
+ Action::Observe { .. } => substrate.sleep(POLL_INTERVAL).await,
+
+ // A pull that will not self-heal: report now rather than spend the
+ // remaining deadline on it. Still no delete authority.
+ Action::Report { name, failure } => {
+ return Err(format!(
+ "{name} did not start: {}",
+ observe::pull_failure_message(failure, cfg.image.as_str())
+ ))
+ }
+
+ Action::AwaitDisappearance { name } => await_disappearance(substrate, &name).await?,
+
+ Action::Delete { name, fence } => {
+ // This call already made its own attempt, and that attempt is
+ // what the classification wants replaced: it terminated (the
+ // deterministic startup failure — the harness starts, rejects
+ // its configuration, exits) or was proven broken. Replacing it
+ // here retries the identical configuration against the same
+ // cluster: a hot delete/mint/create cycle every poll for the
+ // whole deadline, an immutable Secret per cycle — measured
+ // live at 107 Secrets in one 600s call, all younger than the
+ // orphan sweep's age gate. Report in-band instead. The residue
+ // is deliberate: the next Start's preflight GC collects the
+ // terminated pod and its referenced Secret together, so retry
+ // is gated on fresh owner intent and litter stays bounded at
+ // one pod + one Secret per press.
+ if created_this_call {
+ return Err(format!(
+ "{name} was created by this deploy and did not stay \
+ running: {}. Not retrying in this call — an immediate \
+ exit recurs until its cause is fixed. Check the \
+ agent's configuration and press Start to try again.",
+ latest_condition(substrate, identity).await
+ ));
+ }
+ match substrate.delete_pod(&name, &fence).await? {
+ // Accepted or already gone: both need the disappearance
+ // poll before the name is free again.
+ DeleteOutcome::Accepted | DeleteOutcome::NotFound => {
+ await_disappearance(substrate, &name).await?
+ }
+ // The object changed since the observation that authorized
+ // this delete. Discard the action and re-classify — never
+ // retry with a fresher fence, which would delete something
+ // we never examined. Sleep before re-entering: the losing
+ // race is against another writer, and re-reading at full
+ // speed is a busy-retry with no better odds than a paced
+ // one.
+ DeleteOutcome::PreconditionFailed => substrate.sleep(POLL_INTERVAL).await,
+ }
+ }
+
+ Action::Create => {
+ let generation = crate::naming::new_generation();
+ let secret_name = identity.secret_name(&generation);
+
+ // The generation is minted *per attempt*, and it is two things
+ // at once: the Secret's name suffix and the lifecycle
+ // correlator the harness reports. `build_env` stamped the
+ // caller's generation, so on any attempt after the first the
+ // two would name different generations — pod logs correlating
+ // to a Secret that is not the one mounted. Restamp so there is
+ // exactly one generation per attempt (§K8s Secrets).
+ let mut env = env.clone();
+ env.insert(crate::env::START_NONCE_KEY.to_string(), generation.clone());
+
+ // Secret first: the pod's spec references this exact name, so
+ // payload and Secret are atomic at the pod-spec boundary.
+ let secret = crate::pod::build_secret(identity, &cfg.namespace, &generation, env);
+ substrate.create_secret(&secret).await?;
+
+ let pod = crate::pod::build_pod(identity, cfg, &generation, &desired);
+ match substrate.create_pod(&pod).await? {
+ // Re-enter rather than wait inline: the next iteration
+ // observes what we just created and runs the same rows
+ // every other state runs through. One loop, one table.
+ //
+ // Sleep first. A just-created pod cannot already be
+ // started, so the immediate observation has no outcome but
+ // "still coming up" — and if it ever came back
+ // unverifiable, re-entering without advancing the clock
+ // would hot-spin creates against the apiserver for the
+ // whole deadline.
+ CreateOutcome::Created => {
+ created_this_call = true;
+ substrate.sleep(POLL_INTERVAL).await
+ }
+ CreateOutcome::AlreadyExists => {
+ return adopt_winner(substrate, identity, &secret_name).await
+ }
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::Resources;
+ use crate::naming::{ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY};
+ use k8s_openapi::api::core::v1::{
+ ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting,
+ ContainerStatus, PodStatus,
+ };
+ use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
+ use std::cell::RefCell;
+ use std::future::Future;
+ use std::pin::pin;
+ use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
+
+ /// Mutates the pod map after a poll, so a test can script a pod that
+ /// starts (or vanishes) partway through an observation loop.
+ type PollHook = Box)>;
+
+ /// A scripted cluster. Single-threaded on purpose: the reconciler is one
+ /// process per operation, and `RefCell` keeps the assertions readable.
+ ///
+ /// This drives the *shipped* `deploy` — the point of the `Substrate` seam.
+ /// Every fake here answers with real `k8s_openapi` objects, so the decode
+ /// and verification layers under test are the ones that run in a cluster.
+ #[derive(Default)]
+ struct Fake {
+ pods: RefCell>,
+ secrets: RefCell>,
+ /// Server clock for the GC age gate; `None` models a missing `Date`.
+ server_now: Option>,
+ /// Elapsed time, advanced only by `sleep` — a fake clock, so a 600s
+ /// deadline test runs instantly and cannot flake on a slow machine.
+ elapsed: RefCell,
+ /// Queued create outcomes; the default is `Created`.
+ create_outcomes: RefCell>,
+ /// Installed when a create loses the race. A winner must be *absent*
+ /// at the observation that decides to create and *present* by the time
+ /// the create lands — pre-seeding it instead makes the loop no-op
+ /// before it ever reaches the create edge.
+ winner: RefCell>,
+ /// Every Secret ever created, retained across deletion.
+ created_secrets: RefCell>,
+ /// Queued delete outcomes; the default is `Accepted`.
+ delete_outcomes: RefCell>,
+ /// Every mutating call, in order — the anti-mutation assertions read
+ /// this rather than guessing from final state.
+ calls: RefCell>,
+ /// Applied to the pod map after each poll, so a test can script a pod
+ /// that starts (or vanishes) partway through an observation loop.
+ on_poll: RefCell>,
+ /// `ensure_namespace` fails with this, if set.
+ namespace_error: Option,
+ }
+
+ impl Fake {
+ fn with_pod(self, pod: Pod) -> Self {
+ self.pods
+ .borrow_mut()
+ .insert(pod.metadata.name.clone().unwrap(), pod);
+ self
+ }
+ fn log(&self, entry: impl Into) {
+ self.calls.borrow_mut().push(entry.into());
+ }
+ fn mutations(&self) -> Vec {
+ self.calls.borrow().clone()
+ }
+ }
+
+ impl Substrate for Fake {
+ async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> {
+ match &self.namespace_error {
+ Some(e) => Err(e.clone()),
+ None => {
+ self.log(format!("ensure_namespace {namespace}"));
+ Ok(())
+ }
+ }
+ }
+
+ async fn list_pods(
+ &self,
+ _selector: &str,
+ ) -> Result<(Vec, Option