From 3421731d12a69a474e8291b87d25067c8e5f6bd0 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 08:42:28 -0700 Subject: [PATCH 01/14] fix(release): publish authenticated Mono native artifacts --- .github/workflows/release.yml | 138 +++++++++++++--------------------- 1 file changed, 52 insertions(+), 86 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2f07807a..f347e197d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ name: release on: - push: - tags: ["v*.*.*"] + repository_dispatch: + types: [maestro-signed-release] workflow_dispatch: inputs: version: @@ -14,7 +14,7 @@ permissions: concurrency: # A tag push and a manual dispatch can target the same immutable release tag # from different refs. Serialize releases so those paths cannot publish twice. - group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && (startsWith(inputs.version, 'v') && inputs.version || format('v{0}', inputs.version)) || github.ref_name }} + group: ${{ github.workflow }}-${{ github.event.client_payload.version || inputs.version }} cancel-in-progress: false jobs: @@ -38,7 +38,7 @@ jobs: name: Resolve immutable release tag env: EVENT_NAME: ${{ github.event_name }} - REQUESTED: ${{ github.event.inputs.version || github.ref_name }} + REQUESTED: ${{ github.event.client_payload.version || github.event.inputs.version || github.ref_name }} TRIGGER_SHA: ${{ github.sha }} run: | set -euo pipefail @@ -55,7 +55,7 @@ jobs: npm_tag=latest fi release_sha="$TRIGGER_SHA" - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$EVENT_NAME" == "workflow_dispatch" || "$EVENT_NAME" == "repository_dispatch" ]]; then for attempt in 1 2 3; do if timeout 60s git \ -c http.lowSpeedLimit=1000 \ @@ -98,66 +98,50 @@ jobs: binaries: needs: prepare + runs-on: ${{ vars.PUBLIC_RELEASE_RUNNER || 'ubuntu-latest' }} + timeout-minutes: 15 permissions: contents: read - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, target: x86_64-unknown-linux-gnu, os: ubuntu-latest } - - { platform: linux-arm64, target: aarch64-unknown-linux-gnu, os: ubuntu-24.04-arm } - - { platform: darwin-x64, target: x86_64-apple-darwin, os: macos-15 } - - { platform: darwin-arm64, target: aarch64-apple-darwin, os: macos-15 } - runs-on: ${{ matrix.os }} - timeout-minutes: 90 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ needs.prepare.outputs.release_sha }} - - uses: ./.github/actions/setup-rust - with: - toolchain: stable - targets: ${{ matrix.target }} - workspaces: . -> target - - name: Build canonical native binary - env: - RELEASE_PLATFORM: ${{ matrix.platform }} - run: node scripts/build-release-binary.mjs --platform "$RELEASE_PLATFORM" - - name: Smoke host binary - env: - RELEASE_PLATFORM: ${{ matrix.platform }} - run: node scripts/smoke-release-native-only.mjs "dist/release/maestro-${RELEASE_PLATFORM}" - - name: Record smoke marker - env: - RELEASE_PLATFORM: ${{ matrix.platform }} + - name: Install cosign run: | - binary="dist/release/maestro-${RELEASE_PLATFORM}" - if command -v sha256sum >/dev/null 2>&1; then - digest="$(sha256sum "$binary" | awk '{print $1}')" - else - digest="$(shasum -a 256 "$binary" | awk '{print $1}')" - fi - printf '%s maestro-%s\n' "$digest" "$RELEASE_PLATFORM" > "dist/release/smoked-${RELEASE_PLATFORM}.txt" - - name: Verify macOS release signature - if: ${{ startsWith(matrix.platform, 'darwin-') }} + set -euo pipefail + cosign_version="2.6.1" + cosign_sha256="064954c5d8c7e3b28188eee5b1727b31c411550bc5fefd41aa672d3c761d103a" + mkdir -p "$RUNNER_TEMP/cosignbin" + curl -fsSL --max-time 180 --retry 2 --retry-delay 5 \ + -o "$RUNNER_TEMP/cosignbin/cosign" \ + "https://github.com/sigstore/cosign/releases/download/v${cosign_version}/cosign-linux-amd64" + echo "${cosign_sha256} $RUNNER_TEMP/cosignbin/cosign" | sha256sum -c - + chmod +x "$RUNNER_TEMP/cosignbin/cosign" + echo "$RUNNER_TEMP/cosignbin" >> "$GITHUB_PATH" + - name: Download signed Mono artifacts env: - RELEASE_PLATFORM: ${{ matrix.platform }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} run: | - node scripts/check-macos-release-signature.mjs \ - "dist/release/maestro-${RELEASE_PLATFORM}" \ - --marker "dist/release/signed-${RELEASE_PLATFORM}.json" - - name: Record release toolchain + set -euo pipefail + mkdir release-binaries + timeout 300s gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir release-binaries \ + --pattern MONO_SHA256SUMS --pattern MONO_SHA256SUMS.cosign.bundle --pattern release-metadata.json \ + --pattern maestro-linux-x64 --pattern maestro-linux-arm64 --pattern maestro-darwin-x64 --pattern maestro-darwin-arm64 --pattern 'runtime-passport-maestro-*.json' \ + --pattern 'deixic-code-device-*.app.tar.gz' --pattern 'smoked-*.txt' --pattern 'rustc-*.txt' --pattern 'signed-*.json' --pattern 'notarized-*.json' + - name: Authenticate artifacts and release receipts env: - RELEASE_PLATFORM: ${{ matrix.platform }} - run: rustc -Vv > "dist/release/rustc-${RELEASE_PLATFORM}.txt" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} + run: | + node scripts/verify-staged-release.mjs release-binaries "$RELEASE_VERSION" + chmod +x release-binaries/maestro-linux-* release-binaries/maestro-darwin-* + for platform in darwin-x64 darwin-arm64; do + tar -xzf "release-binaries/deixic-code-device-${platform}.app.tar.gz" -C release-binaries + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: maestro-${{ matrix.platform }} - path: | - dist/release/maestro-${{ matrix.platform }} - dist/release/smoked-${{ matrix.platform }}.txt - dist/release/signed-${{ matrix.platform }}.json - dist/release/rustc-${{ matrix.platform }}.txt + name: maestro-verified-native + path: release-binaries/* publish: needs: @@ -393,35 +377,9 @@ jobs: RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} run: | set -euo pipefail - compatibility_digest="$(jq -r '.compatibilityDigest' proto/maestro/v1/protocol-compatibility-manifest.json)" - for platform in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do - case "$platform" in - linux-x64) target=x86_64-unknown-linux-gnu ;; - linux-arm64) target=aarch64-unknown-linux-gnu ;; - darwin-x64) target=x86_64-apple-darwin ;; - darwin-arm64) target=aarch64-apple-darwin ;; - esac - digest="sha256:$(sha256sum "release-assets/maestro-${platform}" | awk '{print $1}')" - rustc_identity="$(cat "release-assets/rustc-${platform}.txt")" - node scripts/generate-runtime-passport.mjs \ - --artifact-kind native_binary \ - --artifact-name "maestro-${platform}" \ - --artifact-digest "$digest" \ - --source-sha "$RELEASE_SHA" \ - --compatibility-digest "$compatibility_digest" \ - --launch-spec-version evalops.maestro.hosted-launch-spec.v1 \ - --receipt-version evalops.maestro.runtime-receipt.v1 \ - --rustc "$rustc_identity" \ - --target "$target" \ - --behavior-profile hosted-http-sse-v1 \ - --out "runtime-passport-maestro-${platform}.json" - done - - node scripts/create-release-metadata.mjs \ - --version "$RELEASE_VERSION" \ - --release-tag "$RELEASE_TAG" \ - --source-sha "$RELEASE_SHA" \ - --out release-assets/release-metadata.json + # Preserve the authenticated Mono source and native passports. + # Public packaging must not relabel a binary with the mirror commit SHA. + RELEASE_SHA="$(jq -r '.receipt.sourceSha' release-assets/release-metadata.json)" cp release-assets/release-metadata.json release-assets/version.json node scripts/create-release-channel-manifest.mjs \ --version "$RELEASE_VERSION" \ @@ -433,7 +391,6 @@ jobs: --source-sha "$RELEASE_SHA" \ --out release-assets/channel-manifest.json cp release-assets/channel-manifest.json release-assets/manifest.json - mv runtime-passport-maestro-*.json release-assets/ - name: Sign native release assets working-directory: release-assets run: | @@ -446,7 +403,7 @@ jobs: maestro-web-dist.tar.gz ) files+=(release-metadata.json version.json channel-manifest.json manifest.json) - files+=(runtime-passport-maestro-*.json) + files+=(runtime-passport-maestro-*.json deixic-code-device-*.app.tar.gz) sha256sum "${files[@]}" > SHA256SUMS cosign sign-blob --yes --bundle SHA256SUMS.cosign.bundle SHA256SUMS for binary in maestro-darwin-arm64 maestro-darwin-x64 maestro-linux-arm64 maestro-linux-x64; do @@ -484,9 +441,18 @@ jobs: tag_name: ${{ needs.prepare.outputs.release_tag }} name: Maestro ${{ needs.prepare.outputs.release_version }} generate_release_notes: true + draft: false prerelease: ${{ needs.prepare.outputs.release_channel != 'stable' }} make_latest: ${{ needs.prepare.outputs.release_channel == 'stable' }} - files: release-assets/* + files: | + release-assets/*.json + release-assets/*.tgz + release-assets/*.tar.gz + release-assets/*.txt + release-assets/*SUMS + release-assets/*.bundle + release-assets/maestro-linux-* + release-assets/maestro-darwin-* post-publish-canary: needs: - prepare From ba11387617bae2c79c5c2aa40369069e2da9f9ec Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 08:48:18 -0700 Subject: [PATCH 02/14] fix(release): serialize equivalent version dispatches --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f347e197d..99fe2d637 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,9 @@ on: permissions: contents: read concurrency: - # A tag push and a manual dispatch can target the same immutable release tag - # from different refs. Serialize releases so those paths cannot publish twice. - group: ${{ github.workflow }}-${{ github.event.client_payload.version || inputs.version }} + # Normalize manual and repository dispatch versions to one immutable tag. + # Serialize equivalent versions so those paths cannot publish twice. + group: ${{ github.workflow }}-${{ startsWith(github.event.client_payload.version || inputs.version, 'v') && (github.event.client_payload.version || inputs.version) || format('v{0}', github.event.client_payload.version || inputs.version) }} cancel-in-progress: false jobs: From 3fc5658ef152e48c2ff3ed02b97cf02703422f0a Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 08:51:18 -0700 Subject: [PATCH 03/14] fix(release): require protected public source before finalization --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99fe2d637..95573c1c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ concurrency: jobs: prepare: + if: github.repository == 'evalops/maestro' && github.ref == 'refs/heads/main' runs-on: ${{ vars.PUBLIC_RELEASE_RUNNER || 'ubuntu-latest' }} timeout-minutes: 10 permissions: @@ -79,6 +80,10 @@ jobs: exit 1 fi + # Secrets are used only with source already admitted to protected main. + # A contents-write dispatch must not execute scripts from an arbitrary tag. + timeout 60s git fetch --no-tags origin main + git merge-base --is-ancestor "$release_sha" FETCH_HEAD git checkout --detach "$release_sha" package_version="$(node -p "require('./package.json').version")" package_name="$(node -p "require('./package.json').name")" From 38fd705f905e8f70d96426aa208c44c69f6fa06e Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 08:52:03 -0700 Subject: [PATCH 04/14] fix(release): fetch history needed for source ancestry checks --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95573c1c7..ab566bccd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - fetch-depth: 1 + fetch-depth: 0 - id: release name: Resolve immutable release tag env: From 99ace6cf317f289d450def45e1fc5f27ff5b2b76 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 10:15:41 -0700 Subject: [PATCH 05/14] fix(release): require authenticated source projection manifest --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab566bccd..3f7547067 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,7 +131,7 @@ jobs: set -euo pipefail mkdir release-binaries timeout 300s gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir release-binaries \ - --pattern MONO_SHA256SUMS --pattern MONO_SHA256SUMS.cosign.bundle --pattern release-metadata.json \ + --pattern MONO_SHA256SUMS --pattern MONO_SHA256SUMS.cosign.bundle --pattern release-metadata.json --pattern release-source-manifest.json \ --pattern maestro-linux-x64 --pattern maestro-linux-arm64 --pattern maestro-darwin-x64 --pattern maestro-darwin-arm64 --pattern 'runtime-passport-maestro-*.json' \ --pattern 'deixic-code-device-*.app.tar.gz' --pattern 'smoked-*.txt' --pattern 'rustc-*.txt' --pattern 'signed-*.json' --pattern 'notarized-*.json' - name: Authenticate artifacts and release receipts From edf38e27755c91f407ae7e49c584e3f1f2f709b1 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 10:30:50 -0700 Subject: [PATCH 06/14] fix(release): enforce authenticated handoff workflow contracts --- .../channel-release-contract.test.mjs | 32 +++---- .github/workflows/channel-release.yml | 57 ++++-------- .../check-release-workflow-contract.mjs | 48 ++++++++-- .../check-release-workflow-contract.test.mjs | 87 ++++++++++++++++--- 4 files changed, 144 insertions(+), 80 deletions(-) diff --git a/.github/workflows/channel-release-contract.test.mjs b/.github/workflows/channel-release-contract.test.mjs index bfc0357c2..6dccd23f6 100644 --- a/.github/workflows/channel-release-contract.test.mjs +++ b/.github/workflows/channel-release-contract.test.mjs @@ -5,25 +5,25 @@ import { test } from "node:test"; const workflow = await readFile(new URL("./channel-release.yml", import.meta.url), "utf8"); const releaseWorkflow = await readFile(new URL("./release.yml", import.meta.url), "utf8"); -test("preview schedules keep beta behind alpha", () => { - assert.match(workflow, /"0 5 \* \* \*" # alpha/); - assert.match(workflow, /"30 5 \* \* \*" # beta/); - assert.match(workflow, /source_ref=origin\/main\^/); - assert.match(workflow, /source_ref=origin\/main\n/); -}); - -test("preview publication uses immutable tags and the protected release workflow", () => { - assert.match(workflow, /git push origin "refs\/tags\/\$\{tag\}"/); - assert.match(workflow, /gh workflow run release\.yml --ref "\$tag"/); - assert.match(workflow, /cp scripts\/sync-package-metadata\.js "\$RUNNER_TEMP\/sync-package-metadata\.js"/); - assert.match(workflow, /cp "\$RUNNER_TEMP\/sync-package-metadata\.js" scripts\/sync-package-metadata\.js/); - assert.doesNotMatch(workflow, /--force/); - assert.doesNotMatch(workflow, /cancel-in-progress: true/); +test("preview schedules finalize only staged signed candidates", () => { + assert.match(workflow, /"0 5 \* \* \*" # alpha/); + assert.match(workflow, /"30 5 \* \* \*" # beta/); + assert.match(workflow, /MONO_SHA256SUMS\.cosign\.bundle/); + assert.match(workflow, /select\(\.draft/); + assert.match(workflow, /contents: read/); + assert.doesNotMatch(workflow, /contents: write/); + assert.match(workflow, /gh workflow run release\.yml --ref main/); + assert.match(workflow, /No staged signed/); + assert.doesNotMatch(workflow, /git (?:push|tag|commit)/); + assert.doesNotMatch(workflow, /scripts\/version\.js/); + assert.doesNotMatch(workflow, /cancel-in-progress: true/); }); test("channel pointers carry the signed native release contract", () => { assert.match(releaseWorkflow, /id-token: write/); - assert.match(releaseWorkflow, /create-release-metadata\.mjs/); + assert.match(releaseWorkflow, /verify-staged-release\.mjs/); + assert.match(releaseWorkflow, /\.receipt\.sourceSha/); + assert.doesNotMatch(releaseWorkflow, /create-release-metadata\.mjs/); assert.match(releaseWorkflow, /create-release-channel-manifest\.mjs/); assert.match(releaseWorkflow, /softprops\/action-gh-release@[0-9a-f]{40}/); assert.match(releaseWorkflow, /release-assets\/channel-manifest\.json/); @@ -31,7 +31,7 @@ test("channel pointers carry the signed native release contract", () => { assert.match(releaseWorkflow, /release-assets\/version\.json/); assert.match(releaseWorkflow, /cosign sign-blob --yes --bundle SHA256SUMS\.cosign\.bundle/); assert.match(releaseWorkflow, /cosign sign-blob --yes --bundle "\$\{binary\}\.cosign\.bundle"/); - assert.match(releaseWorkflow, /files: release-assets\/\*/); + assert.match(releaseWorkflow, /files: \|\n\s+release-assets\/\*\.json/); assert.doesNotMatch(releaseWorkflow, /\.\/\.github\/actions\/gcs-artifacts/); assert.doesNotMatch(releaseWorkflow, /MAESTRO_RELEASES_PREFIX/); assert.doesNotMatch(releaseWorkflow, /gcloud storage/); diff --git a/.github/workflows/channel-release.yml b/.github/workflows/channel-release.yml index d79cd95cc..a31709f8c 100644 --- a/.github/workflows/channel-release.yml +++ b/.github/workflows/channel-release.yml @@ -3,7 +3,7 @@ name: preview-channel-release on: schedule: - cron: "0 5 * * *" # alpha - - cron: "30 5 * * *" # beta, after alpha but from one commit earlier + - cron: "30 5 * * *" # beta, after alpha workflow_dispatch: inputs: channel: @@ -23,20 +23,14 @@ concurrency: jobs: publish-channel-source: + if: github.repository == 'evalops/maestro' && github.ref == 'refs/heads/main' runs-on: ${{ vars.PUBLIC_RELEASE_RUNNER || 'ubuntu-latest' }} timeout-minutes: 30 permissions: actions: write - contents: write + contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - fetch-depth: 2 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: 24 - package-manager-cache: false - - name: Create immutable preview source + - name: Select staged signed preview id: preview env: GH_TOKEN: ${{ github.token }} @@ -52,49 +46,30 @@ jobs: *) echo "::error::Unknown channel schedule: ${SCHEDULE}"; exit 1 ;; esac fi - - git fetch --no-tags origin main - stable_version="$(git show origin/main:package.json | jq -r .version)" - cp scripts/channel-version.mjs "$RUNNER_TEMP/channel-version.mjs" - cp scripts/sync-package-metadata.js "$RUNNER_TEMP/sync-package-metadata.js" - cp .github/workflows/release.yml "$RUNNER_TEMP/release.yml" - release="$(node "$RUNNER_TEMP/channel-version.mjs" "$stable_version" "$channel" "$GITHUB_RUN_NUMBER")" - source_offset="$(jq -r .sourceOffset <<<"$release")" - version="$(jq -r .version <<<"$release")" - source_ref=origin/main - if [[ "$source_offset" == "1" ]]; then - source_ref=origin/main^ + case "$channel" in alpha|beta) ;; *) exit 1 ;; esac + # Mono owns preview source and signed builds. Finalize an existing + # staged candidate; never synthesize source that differs from its binaries. + tag="$(timeout 60s gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" | + jq -r --arg channel "$channel" '[.[] | select(.draft and (.tag_name | test("-" + $channel + "[.-]"))) | + select(any(.assets[]; .name == "MONO_SHA256SUMS.cosign.bundle"))] | + sort_by(.created_at) | last | .tag_name // empty')" + if [[ -z "$tag" ]]; then + echo "No staged signed ${channel} release is ready." >> "$GITHUB_STEP_SUMMARY" + exit 0 fi - - git checkout --detach "$source_ref" - cp "$RUNNER_TEMP/channel-version.mjs" scripts/channel-version.mjs - cp "$RUNNER_TEMP/sync-package-metadata.js" scripts/sync-package-metadata.js - cp "$RUNNER_TEMP/release.yml" .github/workflows/release.yml - npm ci - node scripts/version.js set "$version" --release-notes-ref "$source_ref" - git config user.name "evalops-release-bot" - git config user.email "release-bot@evalops.dev" - git add -A - git commit -m "Release Maestro ${version}" - tag="v${version}" - git tag "$tag" - git push origin "refs/tags/${tag}" - - gh workflow run release.yml --ref "$tag" --field "version=${version}" + gh workflow run release.yml --ref main --field "version=${tag}" { echo "channel=$channel" - echo "source_ref=$source_ref" echo "tag=$tag" } >> "$GITHUB_OUTPUT" - name: Summarize dispatched release + if: steps.preview.outputs.tag != '' env: CHANNEL: ${{ steps.preview.outputs.channel }} - SOURCE_REF: ${{ steps.preview.outputs.source_ref }} TAG: ${{ steps.preview.outputs.tag }} run: | { echo "### Preview release dispatched" echo "- Channel: ${CHANNEL}" - echo "- Product source: ${SOURCE_REF}" echo "- Immutable tag: ${TAG}" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/check-release-workflow-contract.mjs b/.github/workflows/check-release-workflow-contract.mjs index 34d49bcab..bd382da77 100644 --- a/.github/workflows/check-release-workflow-contract.mjs +++ b/.github/workflows/check-release-workflow-contract.mjs @@ -13,7 +13,7 @@ function indentation(line) { } function scalar(value) { - return value.trim().replace(/^["']|["']$/gu, "").replace(/\s+#.*$/u, ""); + return value.trim().replace(/^(["'])([\s\S]*)\1$/u, "$2").replace(/\s+#.*$/u, ""); } export function parseWorkflow(source) { @@ -159,7 +159,20 @@ export function parseWorkflow(source) { } if (indent === 10 && (stepSection === "env" || stepSection === "with")) { const entry = /^ ([a-zA-Z0-9_-]+):\s*(.*?)\s*$/u.exec(line); - if (entry) step[stepSection][entry[1]] = scalar(entry[2]); + if (entry) { + let value = scalar(entry[2]); + if (value === "|") { + const values = []; + while (index + 1 < lines.length) { + const next = lines[index + 1]; + if (next.trim() && indentation(next) <= 10) break; + index += 1; + if (next.trim()) values.push(next.trim()); + } + value = values.join("\n"); + } + step[stepSection][entry[1]] = value; + } } } @@ -249,7 +262,7 @@ export function validateReleaseWorkflow(source) { failures.push("workflow default permissions must be exactly contents: read"); } const normalizedReleaseConcurrency = - "${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && (startsWith(inputs.version, 'v') && inputs.version || format('v{0}', inputs.version)) || github.ref_name }}"; + "${{ github.workflow }}-${{ startsWith(github.event.client_payload.version || inputs.version, 'v') && (github.event.client_payload.version || inputs.version) || format('v{0}', github.event.client_payload.version || inputs.version) }}"; if (concurrencyGroup !== normalizedReleaseConcurrency) { failures.push( "release workflows must serialize only duplicate paths for the same normalized release tag", @@ -292,7 +305,9 @@ export function validateReleaseWorkflow(source) { ["post-publish-canary", canary], ]) { if ( - job.condition || + (name === "prepare" + ? job.condition !== "github.repository == 'evalops/maestro' && github.ref == 'refs/heads/main'" + : job.condition) || (job.continueOnError && job.continueOnError !== "false") ) { failures.push(`${name} job must not be conditional or ignored`); @@ -315,9 +330,9 @@ export function validateReleaseWorkflow(source) { const prepareCheckout = prepare.steps.find((step) => step.uses.startsWith("actions/checkout@"), ); - if (prepareCheckout?.with["fetch-depth"] !== "1") { + if (prepareCheckout?.with["fetch-depth"] !== "0") { failures.push( - "prepare checkout must be shallow before the bounded immutable tag fetch", + "prepare checkout must include history for protected main ancestry verification", ); } const resolveLines = executableLines(resolveStep?.run ?? ""); @@ -343,7 +358,7 @@ export function validateReleaseWorkflow(source) { if ( !hasExactRecord(resolveStep?.env ?? {}, { EVENT_NAME: "${{ github.event_name }}", - REQUESTED: "${{ github.event.inputs.version || github.ref_name }}", + REQUESTED: "${{ github.event.client_payload.version || github.event.inputs.version || github.ref_name }}", TRIGGER_SHA: "${{ github.sha }}", }) ) { @@ -351,7 +366,7 @@ export function validateReleaseWorkflow(source) { } const triggerShaIndex = resolveLines.indexOf('release_sha="$TRIGGER_SHA"'); const dispatchIndex = resolveLines.indexOf( - 'if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then', + 'if [[ "$EVENT_NAME" == "workflow_dispatch" || "$EVENT_NAME" == "repository_dispatch" ]]; then', ); const fetchedShaIndex = resolveLines.indexOf( 'release_sha="$(git rev-list -n 1 "$release_tag")"', @@ -377,6 +392,12 @@ export function validateReleaseWorkflow(source) { if (!topLevelResolveLines.includes('git checkout --detach "$release_sha"')) { failures.push("immutable release resolution must check out the resolved SHA"); } + const ancestryIndex = topLevelResolveLines.indexOf('git merge-base --is-ancestor "$release_sha" FETCH_HEAD'); + if (ancestryIndex < 0 || topLevelResolveLines.indexOf('timeout 60s git fetch --no-tags origin main') >= ancestryIndex || + !topLevelResolveLines.includes('timeout 60s git fetch --no-tags origin main') || + ancestryIndex >= topLevelResolveLines.indexOf('git checkout --detach "$release_sha"')) { + failures.push("release source must pass protected main ancestry verification before checkout"); + } const boundedFetch = 'fetch --force --no-tags origin "refs/tags/${release_tag}:refs/tags/${release_tag}"; then'; if ( @@ -413,6 +434,15 @@ export function validateReleaseWorkflow(source) { failures.push("immutable resolver outputs must use one grouped GITHUB_OUTPUT write"); } + const authenticate = findStep(binaries, "Authenticate artifacts and release receipts"); + const authLines = executableLines(authenticate?.run ?? ""); + const verifyCommand = 'node scripts/verify-staged-release.mjs release-binaries "$RELEASE_VERSION"'; + const authIndex = binaries.steps.indexOf(authenticate); + if (requiredStepCanBeSkippedOrIgnored(authenticate) || authLines[0] !== verifyCommand || + authenticate?.env.RELEASE_VERSION !== "${{ needs.prepare.outputs.release_version }}" || + binaries.steps.some((step, index) => index < authIndex && (step.run.includes("tar -x") || step.uses.startsWith("actions/upload-artifact@")))) { + failures.push("native artifacts must be authenticated for the immutable version before extraction or upload"); + } if (!hasNeed(binaries, "prepare")) failures.push("binaries must need prepare"); if (!hasNeed(publish, "prepare") || !hasNeed(publish, "binaries")) { failures.push("publish must need prepare and binaries"); @@ -662,7 +692,7 @@ export function validateReleaseWorkflow(source) { "${{ needs.prepare.outputs.release_tag }}" || releaseStep?.with.name !== "Maestro ${{ needs.prepare.outputs.release_version }}" || - releaseStep?.with.files !== "release-assets/*" + releaseStep?.with.files !== "release-assets/*.json\nrelease-assets/*.tgz\nrelease-assets/*.tar.gz\nrelease-assets/*.txt\nrelease-assets/*SUMS\nrelease-assets/*.bundle\nrelease-assets/maestro-linux-*\nrelease-assets/maestro-darwin-*" ) { failures.push( "GitHub release metadata and files must bind to immutable prepare outputs", diff --git a/.github/workflows/check-release-workflow-contract.test.mjs b/.github/workflows/check-release-workflow-contract.test.mjs index bb4bb4fe0..86374da15 100644 --- a/.github/workflows/check-release-workflow-contract.test.mjs +++ b/.github/workflows/check-release-workflow-contract.test.mjs @@ -12,9 +12,10 @@ const completeWorkflow = ` permissions: contents: read concurrency: - group: \${{ github.workflow }}-\${{ github.event_name == 'workflow_dispatch' && (startsWith(inputs.version, 'v') && inputs.version || format('v{0}', inputs.version)) || github.ref_name }} + group: \${{ github.workflow }}-\${{ startsWith(github.event.client_payload.version || inputs.version, 'v') && (github.event.client_payload.version || inputs.version) || format('v{0}', github.event.client_payload.version || inputs.version) }} jobs: prepare: + if: github.repository == 'evalops/maestro' && github.ref == 'refs/heads/main' runs-on: \${{ vars.PUBLIC_RELEASE_RUNNER || 'ubuntu-latest' }} permissions: contents: read @@ -28,16 +29,16 @@ jobs: steps: - uses: actions/checkout@sha with: - fetch-depth: 1 + fetch-depth: 0 - id: release name: Resolve immutable release tag env: EVENT_NAME: \${{ github.event_name }} - REQUESTED: \${{ github.event.inputs.version || github.ref_name }} + REQUESTED: \${{ github.event.client_payload.version || github.event.inputs.version || github.ref_name }} TRIGGER_SHA: \${{ github.sha }} run: | release_sha="$TRIGGER_SHA" - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$EVENT_NAME" == "workflow_dispatch" || "$EVENT_NAME" == "repository_dispatch" ]]; then for attempt in 1 2 3; do if timeout 60s git \\ -c http.lowSpeedLimit=1000 \\ @@ -54,6 +55,8 @@ jobs: elif [[ "$EVENT_NAME" != "push" ]]; then exit 1 fi + timeout 60s git fetch --no-tags origin main + git merge-base --is-ancestor "$release_sha" FETCH_HEAD git checkout --detach "$release_sha" { echo "package_name=$package_name" @@ -71,6 +74,11 @@ jobs: - uses: actions/checkout@sha with: ref: ${releaseSha} + - name: Authenticate artifacts and release receipts + env: + RELEASE_VERSION: \${{ needs.prepare.outputs.release_version }} + run: | + node scripts/verify-staged-release.mjs release-binaries "$RELEASE_VERSION" publish: needs: [prepare, binaries] runs-on: \${{ vars.PUBLIC_RELEASE_RUNNER || 'ubuntu-latest' }} @@ -242,7 +250,15 @@ jobs: with: tag_name: \${{ needs.prepare.outputs.release_tag }} name: Maestro \${{ needs.prepare.outputs.release_version }} - files: release-assets/* + files: | + release-assets/*.json + release-assets/*.tgz + release-assets/*.tar.gz + release-assets/*.txt + release-assets/*SUMS + release-assets/*.bundle + release-assets/maestro-linux-* + release-assets/maestro-darwin-* post-publish-canary: needs: - prepare @@ -329,7 +345,7 @@ test("rejects broad build permissions and non-serialized releases", () => { const broadened = completeWorkflow .replace("permissions:\n contents: read\n", "permissions:\n contents: write\n") .replace( - " group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && (startsWith(inputs.version, 'v') && inputs.version || format('v{0}', inputs.version)) || github.ref_name }}\n", + " group: ${{ github.workflow }}-${{ startsWith(github.event.client_payload.version || inputs.version, 'v') && (github.event.client_payload.version || inputs.version) || format('v{0}', github.event.client_payload.version || inputs.version) }}\n", " group: ${{ github.workflow }}\n", ) .replace( @@ -378,14 +394,14 @@ test("rejects workflow_dispatch work from an unbound ref", () => { ); }); -test("rejects a full-history prepare checkout before bounded tag resolution", () => { +test("rejects shallow history that cannot prove protected main ancestry", () => { const fullHistory = completeWorkflow.replace( - " fetch-depth: 1\n", " fetch-depth: 0\n", + " fetch-depth: 1\n", ); assert.ok( validateReleaseWorkflow(fullHistory).some((failure) => - failure.includes("prepare checkout must be shallow"), + failure.includes("prepare checkout must include history"), ), ); }); @@ -394,7 +410,7 @@ test("rejects tag-push source replacement with a freshly resolved tag", () => { const movedTag = completeWorkflow .replace(' release_sha="$TRIGGER_SHA"\n', "") .replace( - ' if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then\n', + ' if [[ "$EVENT_NAME" == "workflow_dispatch" || "$EVENT_NAME" == "repository_dispatch" ]]; then\n', "", ) .replace( @@ -823,7 +839,15 @@ test("rejects a non-retryable or incomplete GitHub release job", () => { with: tag_name: \${{ needs.prepare.outputs.release_tag }} name: Maestro \${{ needs.prepare.outputs.release_version }} - files: release-assets/* + files: | + release-assets/*.json + release-assets/*.tgz + release-assets/*.tar.gz + release-assets/*.txt + release-assets/*SUMS + release-assets/*.bundle + release-assets/maestro-linux-* + release-assets/maestro-darwin-* `, "", ) @@ -833,7 +857,15 @@ test("rejects a non-retryable or incomplete GitHub release job", () => { with: tag_name: \${{ needs.prepare.outputs.release_tag }} name: Maestro \${{ needs.prepare.outputs.release_version }} - files: release-assets/* + files: | + release-assets/*.json + release-assets/*.tgz + release-assets/*.tar.gz + release-assets/*.txt + release-assets/*SUMS + release-assets/*.bundle + release-assets/maestro-linux-* + release-assets/maestro-darwin-* - name: Publish to npm `, ); @@ -905,14 +937,30 @@ test("rejects GitHub release retargeting of a detached commit", () => { with: tag_name: \${{ needs.prepare.outputs.release_tag }} name: Maestro \${{ needs.prepare.outputs.release_version }} - files: release-assets/* + files: | + release-assets/*.json + release-assets/*.tgz + release-assets/*.tar.gz + release-assets/*.txt + release-assets/*SUMS + release-assets/*.bundle + release-assets/maestro-linux-* + release-assets/maestro-darwin-* `, ` - uses: softprops/action-gh-release@sha with: tag_name: \${{ needs.prepare.outputs.release_tag }} target_commitish: \${{ needs.prepare.outputs.release_sha }} name: Maestro \${{ needs.prepare.outputs.release_version }} - files: release-assets/* + files: | + release-assets/*.json + release-assets/*.tgz + release-assets/*.tar.gz + release-assets/*.txt + release-assets/*SUMS + release-assets/*.bundle + release-assets/maestro-linux-* + release-assets/maestro-darwin-* `, ); assert.ok( @@ -977,3 +1025,14 @@ test("versioned browser asset is present in the release source tree", async () = ); assert.match(html, //iu); }); + +for (const line of [' git merge-base --is-ancestor "$release_sha" FETCH_HEAD', ' timeout 60s git fetch --no-tags origin main']) { + test(`rejects missing ancestry guard: ${line.trim()}`, () => { + assert.ok(validateReleaseWorkflow(completeWorkflow.replace(line, "")).some(f => f.includes("ancestry verification"))); + }); +} +for (const replacement of ["echo skipped", 'if false; then node scripts/verify-staged-release.mjs release-binaries "$RELEASE_VERSION"; fi']) { + test(`rejects bypassed authentication: ${replacement}`, () => { + assert.ok(validateReleaseWorkflow(completeWorkflow.replace('node scripts/verify-staged-release.mjs release-binaries "$RELEASE_VERSION"', replacement)).some(f => f.includes("must be authenticated"))); + }); +} From 5bfbf77aa334e42adcec3b9f4fa9617e1ab8f18b Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 12:07:46 -0700 Subject: [PATCH 07/14] fix(release): dispatch staged tag retries from main --- .../channel-release-contract.test.mjs | 13 ++++++ .github/workflows/release.yml | 1 + .github/workflows/tag-release.yml | 44 ++++++++++++++----- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/.github/workflows/channel-release-contract.test.mjs b/.github/workflows/channel-release-contract.test.mjs index 6dccd23f6..eae8117db 100644 --- a/.github/workflows/channel-release-contract.test.mjs +++ b/.github/workflows/channel-release-contract.test.mjs @@ -39,3 +39,16 @@ test("channel pointers carry the signed native release contract", () => { assert.doesNotMatch(releaseWorkflow, /maestro-\$\{RELEASE_CHANNEL\}-channel/); assert.doesNotMatch(releaseWorkflow, /gh release upload "\$channel_tag"/); }); + +const tagWorkflow = await readFile(new URL("./tag-release.yml", import.meta.url), "utf8"); +test("tag retries dispatch main and correlate the normalized release version", () => { + assert.match(releaseWorkflow, /run-name: Release \$\{\{ startsWith/); + assert.match(tagWorkflow, /--ref main/); + assert.doesNotMatch(tagWorkflow, /--ref "\$\{RELEASE_TAG\}"/); + assert.equal((tagWorkflow.match(/\.displayTitle ==/g) || []).length, 4); + assert.equal((tagWorkflow.match(/--json [^\n]*displayTitle/g) || []).length, 4); + assert.match(tagWorkflow, /outputs\.staged_ready == 'true'/); + assert.match(tagWorkflow, /MONO_SHA256SUMS\.cosign\.bundle/); + assert.match(tagWorkflow, /elif grep -q 'HTTP 404'/); + assert.match(tagWorkflow, /cat "\$release_error" >&2\n\s+exit 1/); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f7547067..5e4ac1668 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,5 @@ name: release +run-name: Release ${{ startsWith(github.event.client_payload.version || inputs.version, 'v') && (github.event.client_payload.version || inputs.version) || format('v{0}', github.event.client_payload.version || inputs.version) }} on: repository_dispatch: diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index a0966c745..7d501a382 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -29,6 +29,7 @@ jobs: release_tag: ${{ steps.release.outputs.release_tag }} release_version: ${{ steps.release.outputs.release_version }} tag_exists: ${{ steps.release.outputs.tag_exists }} + staged_ready: ${{ steps.staged-release.outputs.ready }} registry_published: ${{ steps.registry-release.outputs.published }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -79,6 +80,29 @@ jobs: fi + - id: staged-release + name: Check signed Mono staging + if: ${{ github.repository == 'evalops/maestro' }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.release_tag }} + run: | + set -euo pipefail + release_json="$RUNNER_TEMP/staged-release.json" + release_error="$RUNNER_TEMP/staged-release.stderr" + if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" > "$release_json" 2> "$release_error"; then + ready="$(jq 'any(.assets[]; .name == "MONO_SHA256SUMS.cosign.bundle")' "$release_json")" + elif grep -q 'HTTP 404' "$release_error"; then + ready=false + else + cat "$release_error" >&2 + exit 1 + fi + echo "ready=$ready" >> "$GITHUB_OUTPUT" + if [[ "$ready" != true ]]; then + echo "Tag ${RELEASE_TAG} is ready; awaiting signed Mono artifacts." >> "$GITHUB_STEP_SUMMARY" + fi + - id: active-release name: Check active public release workflow if: ${{ github.repository == 'evalops/maestro' && steps.registry-release.outputs.published != 'true' }} @@ -91,9 +115,9 @@ jobs: gh run list \ --repo "${GITHUB_REPOSITORY}" \ --workflow release \ - --json headBranch,status \ + --json headBranch,status,displayTitle \ --limit 50 \ - --jq "[.[] | select(.headBranch == \"${RELEASE_TAG}\" and .status != \"completed\")] | length" + --jq "[.[] | select(.headBranch == \"main\" and .displayTitle == \"Release ${RELEASE_TAG}\" and .status != \"completed\")] | length" )" echo "active_count=${active_count}" >> "$GITHUB_OUTPUT" if [[ "${active_count}" == "0" ]]; then @@ -196,7 +220,7 @@ jobs: needs: - tag-current-version - verify-published-registry-package - if: ${{ always() && github.repository == 'evalops/maestro' && needs.tag-current-version.result == 'success' && (needs.tag-current-version.outputs.registry_published != 'true' || needs.verify-published-registry-package.result == 'success') && (needs.tag-current-version.outputs.tag_exists != 'true' || needs.tag-current-version.outputs.registry_published != 'true') && (needs.tag-current-version.outputs.active_release_count == '0' || needs.tag-current-version.outputs.active_release_count == '') }} + if: ${{ always() && github.repository == 'evalops/maestro' && needs.tag-current-version.result == 'success' && needs.tag-current-version.outputs.staged_ready == 'true' && (needs.tag-current-version.outputs.registry_published != 'true' || needs.verify-published-registry-package.result == 'success') && (needs.tag-current-version.outputs.tag_exists != 'true' || needs.tag-current-version.outputs.registry_published != 'true') && (needs.tag-current-version.outputs.active_release_count == '0' || needs.tag-current-version.outputs.active_release_count == '') }} runs-on: ${{ vars.PUBLIC_PR_VALIDATION_RUNNER || 'ubuntu-latest' }} timeout-minutes: 10 permissions: @@ -216,9 +240,9 @@ jobs: gh run list \ --repo "${GITHUB_REPOSITORY}" \ --workflow release \ - --json headBranch,status \ + --json headBranch,status,displayTitle \ --limit 50 \ - --jq "[.[] | select(.headBranch == \"${RELEASE_TAG}\" and .status != \"completed\")] | length" + --jq "[.[] | select(.headBranch == \"main\" and .displayTitle == \"Release ${RELEASE_TAG}\" and .status != \"completed\")] | length" } # shellcheck disable=SC2317,SC2329 # Invoked indirectly through retry. @@ -226,9 +250,9 @@ jobs: gh run list \ --repo "${GITHUB_REPOSITORY}" \ --workflow release \ - --json event,headBranch \ + --json event,headBranch,displayTitle \ --limit 50 \ - --jq "[.[] | select(.headBranch == \"${RELEASE_TAG}\" and .event == \"workflow_dispatch\")] | length" + --jq "[.[] | select(.headBranch == \"main\" and .displayTitle == \"Release ${RELEASE_TAG}\" and .event == \"workflow_dispatch\")] | length" } # shellcheck disable=SC2317,SC2329 # Invoked indirectly through retry. @@ -236,9 +260,9 @@ jobs: gh run list \ --repo "${GITHUB_REPOSITORY}" \ --workflow release \ - --json databaseId,event,headBranch \ + --json databaseId,event,headBranch,displayTitle \ --limit 50 \ - --jq "[.[] | select(.headBranch == \"${RELEASE_TAG}\" and .event == \"workflow_dispatch\") | .databaseId] | max // empty" + --jq "[.[] | select(.headBranch == \"main\" and .displayTitle == \"Release ${RELEASE_TAG}\" and .event == \"workflow_dispatch\") | .databaseId] | max // empty" } retry() { @@ -266,7 +290,7 @@ jobs: existing_count="$(retry "List dispatched release workflows" dispatched_release_run_count)" existing_latest_run_id="$(retry "List latest dispatched release workflow id" latest_dispatched_release_run_id)" dispatch_status=0 - retry "Dispatch release workflow" gh workflow run release --repo "${GITHUB_REPOSITORY}" --ref "${RELEASE_TAG}" --field "version=${RELEASE_VERSION}" || dispatch_status=$? + retry "Dispatch release workflow" gh workflow run release --repo "${GITHUB_REPOSITORY}" --ref main --field "version=${RELEASE_VERSION}" || dispatch_status=$? for attempt in 1 2 3 4 5 6; do release_run_count="$(retry "Confirm dispatched release workflow" dispatched_release_run_count)" From 45782263d1271371b17007371587de4baab8f649 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 12:15:27 -0700 Subject: [PATCH 08/14] fix(release): preserve signed optional device capability --- .github/workflows/channel-release-contract.test.mjs | 7 +++++++ .github/workflows/release.yml | 13 ++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/channel-release-contract.test.mjs b/.github/workflows/channel-release-contract.test.mjs index eae8117db..2e5e443ac 100644 --- a/.github/workflows/channel-release-contract.test.mjs +++ b/.github/workflows/channel-release-contract.test.mjs @@ -52,3 +52,10 @@ test("tag retries dispatch main and correlate the normalized release version", ( assert.match(tagWorkflow, /elif grep -q 'HTTP 404'/); assert.match(tagWorkflow, /cat "\$release_error" >&2\n\s+exit 1/); }); + +test("only authenticated enabled device helpers are extracted and published", () => { + assert.match(releaseWorkflow, /--pattern 'code-device-\*\.json'/); + assert.match(releaseWorkflow, /verify-staged-release\.mjs[\s\S]*jq -r '\.enabled'[\s\S]*tar -xzf/); + assert.match(releaseWorkflow, /files\+=\(runtime-passport-maestro-\*\.json code-device-\*\.json\)/); + assert.doesNotMatch(releaseWorkflow, /files\+=\([^\n]*deixic-code-device-\*/); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e4ac1668..d04195b3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,7 @@ jobs: timeout 300s gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir release-binaries \ --pattern MONO_SHA256SUMS --pattern MONO_SHA256SUMS.cosign.bundle --pattern release-metadata.json --pattern release-source-manifest.json \ --pattern maestro-linux-x64 --pattern maestro-linux-arm64 --pattern maestro-darwin-x64 --pattern maestro-darwin-arm64 --pattern 'runtime-passport-maestro-*.json' \ - --pattern 'deixic-code-device-*.app.tar.gz' --pattern 'smoked-*.txt' --pattern 'rustc-*.txt' --pattern 'signed-*.json' --pattern 'notarized-*.json' + --pattern 'code-device-*.json' --pattern 'deixic-code-device-*.app.tar.gz' --pattern 'smoked-*.txt' --pattern 'rustc-*.txt' --pattern 'signed-*.json' --pattern 'notarized-*.json' - name: Authenticate artifacts and release receipts env: RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} @@ -142,7 +142,9 @@ jobs: node scripts/verify-staged-release.mjs release-binaries "$RELEASE_VERSION" chmod +x release-binaries/maestro-linux-* release-binaries/maestro-darwin-* for platform in darwin-x64 darwin-arm64; do - tar -xzf "release-binaries/deixic-code-device-${platform}.app.tar.gz" -C release-binaries + if [[ "$(jq -r '.enabled' "release-binaries/code-device-${platform}.json")" == true ]]; then + tar -xzf "release-binaries/deixic-code-device-${platform}.app.tar.gz" -C release-binaries + fi done - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: @@ -409,7 +411,12 @@ jobs: maestro-web-dist.tar.gz ) files+=(release-metadata.json version.json channel-manifest.json manifest.json) - files+=(runtime-passport-maestro-*.json deixic-code-device-*.app.tar.gz) + files+=(runtime-passport-maestro-*.json code-device-*.json) + for platform in darwin-x64 darwin-arm64; do + if [[ "$(jq -r '.enabled' "code-device-${platform}.json")" == true ]]; then + files+=("deixic-code-device-${platform}.app.tar.gz") + fi + done sha256sum "${files[@]}" > SHA256SUMS cosign sign-blob --yes --bundle SHA256SUMS.cosign.bundle SHA256SUMS for binary in maestro-darwin-arm64 maestro-darwin-x64 maestro-linux-arm64 maestro-linux-x64; do From 0d30c50db1beec4f7d4bf1105e2ba6245f121b63 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 12:29:06 -0700 Subject: [PATCH 09/14] fix(release): authenticate capability markers for older source tags --- .github/workflows/channel-release-contract.test.mjs | 8 ++++++++ .github/workflows/release.yml | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/channel-release-contract.test.mjs b/.github/workflows/channel-release-contract.test.mjs index 2e5e443ac..3a7f7546e 100644 --- a/.github/workflows/channel-release-contract.test.mjs +++ b/.github/workflows/channel-release-contract.test.mjs @@ -59,3 +59,11 @@ test("only authenticated enabled device helpers are extracted and published", () assert.match(releaseWorkflow, /files\+=\(runtime-passport-maestro-\*\.json code-device-\*\.json\)/); assert.doesNotMatch(releaseWorkflow, /files\+=\([^\n]*deixic-code-device-\*/); }); + +test("older source verifiers cannot authorize unhashed capability markers", () => { + const authentication = releaseWorkflow.indexOf('node scripts/verify-staged-release.mjs release-binaries'); + const markerCheck = releaseWorkflow.indexOf('code-device-${platform}\\.json$'); + const decision = releaseWorkflow.indexOf("jq -r '.enabled'"); + assert.ok(authentication >= 0 && authentication < markerCheck && markerCheck < decision); + assert.match(releaseWorkflow, /MONO_SHA256SUMS \| sha256sum --check --strict/); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d04195b3c..0602fe73f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -139,7 +139,16 @@ jobs: env: RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} run: | + set -euo pipefail node scripts/verify-staged-release.mjs release-binaries "$RELEASE_VERSION" + # Also enforce marker authentication here when dispatching an older tag + # whose source verifier predates the optional capability contract. + for platform in darwin-x64 darwin-arm64; do + ( + cd release-binaries + grep -E "^[a-f0-9]{64} code-device-${platform}\.json$" MONO_SHA256SUMS | sha256sum --check --strict + ) + done chmod +x release-binaries/maestro-linux-* release-binaries/maestro-darwin-* for platform in darwin-x64 darwin-arm64; do if [[ "$(jq -r '.enabled' "release-binaries/code-device-${platform}.json")" == true ]]; then From 4620704a80c373e6dbc40dd66ebf9df2784f409f Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 12:29:39 -0700 Subject: [PATCH 10/14] test(release): accept only strict shell preamble before authentication --- .github/workflows/check-release-workflow-contract.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/check-release-workflow-contract.mjs b/.github/workflows/check-release-workflow-contract.mjs index bd382da77..20fa117f6 100644 --- a/.github/workflows/check-release-workflow-contract.mjs +++ b/.github/workflows/check-release-workflow-contract.mjs @@ -436,6 +436,8 @@ export function validateReleaseWorkflow(source) { const authenticate = findStep(binaries, "Authenticate artifacts and release receipts"); const authLines = executableLines(authenticate?.run ?? ""); + // A strict shell preamble is permitted; no other command may precede authentication. + if (authLines[0] === "set -euo pipefail") authLines.shift(); const verifyCommand = 'node scripts/verify-staged-release.mjs release-binaries "$RELEASE_VERSION"'; const authIndex = binaries.steps.indexOf(authenticate); if (requiredStepCanBeSkippedOrIgnored(authenticate) || authLines[0] !== verifyCommand || From f1c64811b1dd655bd6a5caf01bce8328d2c80ce3 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 12:30:30 -0700 Subject: [PATCH 11/14] test(release): execute missing and altered marker checks --- .github/workflows/device-marker-auth.test.mjs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/device-marker-auth.test.mjs diff --git a/.github/workflows/device-marker-auth.test.mjs b/.github/workflows/device-marker-auth.test.mjs new file mode 100644 index 000000000..45b255060 --- /dev/null +++ b/.github/workflows/device-marker-auth.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const workflow = readFileSync(new URL('./release.yml', import.meta.url), 'utf8'); +const authentication = workflow.split('node scripts/verify-staged-release.mjs release-binaries "$RELEASE_VERSION"')[1].split(' chmod')[0]; +for (const mode of ['valid', 'changed', 'missing']) { + test(`older-source marker authentication: ${mode}`, t => { + const root = mkdtempSync(join(tmpdir(), 'device-marker-')); + t.after(() => rmSync(root, {recursive:true, force:true})); + const dir = join(root, 'release-binaries'); mkdirSync(dir); + const lines = []; + for (const platform of ['darwin-x64', 'darwin-arm64']) { + const name = `code-device-${platform}.json`; + const content = JSON.stringify({schemaVersion:1,platform,enabled:false}); + writeFileSync(join(dir,name), content); + lines.push(`${createHash('sha256').update(content).digest('hex')} ${name}`); + } + if (mode === 'changed') writeFileSync(join(dir,'code-device-darwin-arm64.json'),'{}'); + if (mode === 'missing') lines.pop(); + writeFileSync(join(dir,'MONO_SHA256SUMS'),lines.join('\n')+'\n'); + const result = spawnSync('bash', ['-e','-o','pipefail','-c',authentication], {cwd:root,encoding:'utf8'}); + if (mode === 'valid') assert.equal(result.status,0,result.stderr); + else assert.notEqual(result.status,0); + }); +} From 5b510b32b31a1f11f42eb555fae3cbbefbc35463 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 12:39:26 -0700 Subject: [PATCH 12/14] test(release): run marker authentication cases in required CI --- .github/workflows/check-release-workflow-contract.test.mjs | 1 + ...{device-marker-auth.test.mjs => device-marker-auth-cases.mjs} | 0 2 files changed, 1 insertion(+) rename .github/workflows/{device-marker-auth.test.mjs => device-marker-auth-cases.mjs} (100%) diff --git a/.github/workflows/check-release-workflow-contract.test.mjs b/.github/workflows/check-release-workflow-contract.test.mjs index 86374da15..b4b783a9e 100644 --- a/.github/workflows/check-release-workflow-contract.test.mjs +++ b/.github/workflows/check-release-workflow-contract.test.mjs @@ -1,3 +1,4 @@ +import "./device-marker-auth-cases.mjs"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { test } from "node:test"; diff --git a/.github/workflows/device-marker-auth.test.mjs b/.github/workflows/device-marker-auth-cases.mjs similarity index 100% rename from .github/workflows/device-marker-auth.test.mjs rename to .github/workflows/device-marker-auth-cases.mjs From 7534d285da2791899b4ab35be36a2fd65092899f Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 5 Sep 2026 12:43:04 -0700 Subject: [PATCH 13/14] fix(release): reject stale disabled helper archives before publication --- .github/workflows/device-marker-auth-cases.mjs | 3 ++- .github/workflows/release.yml | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/device-marker-auth-cases.mjs b/.github/workflows/device-marker-auth-cases.mjs index 45b255060..3e29ef9c3 100644 --- a/.github/workflows/device-marker-auth-cases.mjs +++ b/.github/workflows/device-marker-auth-cases.mjs @@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process'; const workflow = readFileSync(new URL('./release.yml', import.meta.url), 'utf8'); const authentication = workflow.split('node scripts/verify-staged-release.mjs release-binaries "$RELEASE_VERSION"')[1].split(' chmod')[0]; -for (const mode of ['valid', 'changed', 'missing']) { +for (const mode of ['valid', 'changed', 'missing', 'unexpected-helper']) { test(`older-source marker authentication: ${mode}`, t => { const root = mkdtempSync(join(tmpdir(), 'device-marker-')); t.after(() => rmSync(root, {recursive:true, force:true})); @@ -22,6 +22,7 @@ for (const mode of ['valid', 'changed', 'missing']) { } if (mode === 'changed') writeFileSync(join(dir,'code-device-darwin-arm64.json'),'{}'); if (mode === 'missing') lines.pop(); + if (mode === 'unexpected-helper') writeFileSync(join(dir,'deixic-code-device-darwin-arm64.app.tar.gz'),'stale helper'); writeFileSync(join(dir,'MONO_SHA256SUMS'),lines.join('\n')+'\n'); const result = spawnSync('bash', ['-e','-o','pipefail','-c',authentication], {cwd:root,encoding:'utf8'}); if (mode === 'valid') assert.equal(result.status,0,result.stderr); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0602fe73f..6a5dc911c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,6 +147,10 @@ jobs: ( cd release-binaries grep -E "^[a-f0-9]{64} code-device-${platform}\.json$" MONO_SHA256SUMS | sha256sum --check --strict + if [[ "$(jq -r '.enabled' "code-device-${platform}.json")" != true && -e "deixic-code-device-${platform}.app.tar.gz" ]]; then + echo "::error::Disabled Code device helper must not be present in release assets." + exit 1 + fi ) done chmod +x release-binaries/maestro-linux-* release-binaries/maestro-darwin-* From 2c552190138ba19b6312f05b7f43c8ecda379b4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 6 Sep 2026 04:07:06 +0000 Subject: [PATCH 14/14] chore: sync public mirror from internal --- packages/tui-rs/src/agent/compaction.rs | 7 +- packages/tui-rs/src/agent/native.rs | 172 +++++++++++++++-- packages/tui-rs/src/app.rs | 24 +++ packages/tui-rs/src/app/command_handlers.rs | 14 +- packages/tui-rs/src/app/context_breakdown.rs | 56 ++++++ packages/tui-rs/src/app/tests.rs | 69 +++++++ packages/tui-rs/src/commands/mod.rs | 2 +- packages/tui-rs/src/commands/registry.rs | 44 ++++- .../tui-rs/src/commands/registry/tests.rs | 53 +++++- packages/tui-rs/src/commands/types.rs | 30 +++ packages/tui-rs/src/components/message.rs | 45 +++++ packages/tui-rs/src/harness.rs | 2 + packages/tui-rs/src/model_dynamics.rs | 2 + packages/tui-rs/src/tools/context_tools.rs | 2 +- packages/tui-rs/src/tools/registry.rs | 8 + .../registry/coding_task/dispatcher_tests.rs | 1 + .../src/tools/registry/tool_registry.rs | 1 + packages/tui-rs/src/tools/subagents.rs | 178 +++++++++++++++++- scripts/check-buildkite-pipeline.test.mjs | 47 ++++- scripts/run-buildkite-jetbrains.sh | 6 +- .../tui-capture/context-guidance.json | 9 + 21 files changed, 737 insertions(+), 35 deletions(-) create mode 100644 test/fixtures/tui-capture/context-guidance.json diff --git a/packages/tui-rs/src/agent/compaction.rs b/packages/tui-rs/src/agent/compaction.rs index 2d9726e98..a1d5dd5b9 100644 --- a/packages/tui-rs/src/agent/compaction.rs +++ b/packages/tui-rs/src/agent/compaction.rs @@ -430,7 +430,7 @@ fn command_from_input(input: &serde_json::Value) -> Option { .map(str::to_string) } -fn build_continuation_record(messages: &[Message]) -> ContinuationRecord { +pub(crate) fn build_continuation_record(messages: &[Message]) -> ContinuationRecord { let mut record = ContinuationRecord::default(); let mut commands_by_id = HashMap::::new(); @@ -559,9 +559,12 @@ impl ContinuationRecord { fn to_markdown(&self) -> String { let mut sections = Vec::new(); + if !self.user_requests.is_empty() { + sections.push(format!("Latest user request: #{}. Later corrections replace only what they change; other boundaries remain.", self.user_requests.len())); + } if !self.constraints.is_empty() { sections.push(format!( - "## Constraints\n- {}", + "## Earlier constraint excerpts (check later user corrections)\n- {}", self.constraints.join("\n- ") )); } diff --git a/packages/tui-rs/src/agent/native.rs b/packages/tui-rs/src/agent/native.rs index d025f6e66..a6cad432d 100644 --- a/packages/tui-rs/src/agent/native.rs +++ b/packages/tui-rs/src/agent/native.rs @@ -4357,7 +4357,8 @@ impl NativeAgentRunner { return; } - let result = self.compactor.compact_with_tokens(&self.messages); + let mut result = self.compactor.compact_with_tokens(&self.messages); + self.retain_continuation(&mut result); if !result.was_compacted() { return; } @@ -6595,6 +6596,69 @@ impl NativeAgentRunner { Ok(()) } + fn selected_summary_model(&self) -> Result { + let model = self + .config + .model_dynamics + .summary_model + .clone() + .unwrap_or_else(|| self.config.model.clone()); + if model.trim().is_empty() { + anyhow::bail!("Summary model must not be empty"); + } + if let Some(reason) = check_model_allowed(&policy_model_id(&model)) { + anyhow::bail!(reason); + } + if crate::codex_auth::resolve_model_route(&model).uses_app_server() + != self.model_route.uses_app_server() + { + anyhow::bail!("Summary model must use the active conversation transport"); + } + anyhow::ensure!( + policy_model_id(&model) + .split_once('/') + .map(|(provider, _)| provider) + == policy_model_id(&self.config.model) + .split_once('/') + .map(|(provider, _)| provider), + "Summary model must use the active provider and connection profile" + ); + Ok(model) + } + + fn build_summary_config(&mut self, messages: &[Message]) -> Result { + let model = self.selected_summary_model()?; + let mut config = self.build_config(messages, false)?; + config.max_tokens = config.max_tokens.min(2048); + config.thinking = None; + config.temperature = Some(0.0); + if model != self.config.model { + let info = crate::model_catalog::find_model(&model) + .context("Summary model context capacity is unknown")?; + let input = super::token_counting::count_tokens( + &serde_json::to_string(messages)?, + Some(&model), + ) + .saturating_add(super::token_counting::count_tokens( + config.system.as_deref().unwrap_or_default(), + Some(&model), + )); + config.max_tokens = clamp_output_to_remaining_context( + config.max_tokens, + info.capabilities.context_tokens, + input, + ) + .context("Selected history does not fit the summary model")?; + config.model = if model.starts_with("evalops/") || model.starts_with("maestro-managed/") + { + model + } else { + provider_model_name(&model) + }; + } + Ok(config) + } + async fn run_selective_summary( &mut self, selection: super::RangeSelection, @@ -6634,10 +6698,7 @@ impl NativeAgentRunner { role: Role::User, content: MessageContent::text(prompt), }); - let mut config = self.build_config(&messages, false)?; - config.max_tokens = config.max_tokens.min(2048); - config.thinking = None; - config.temperature = Some(0.0); + let config = self.build_summary_config(&messages)?; let client = self .client .as_ref() @@ -6729,8 +6790,9 @@ impl NativeAgentRunner { usage: &mut TokenUsage, saw_usage: &mut bool, ) -> Result<()> { + let model = self.selected_summary_model()?; let (result, reported_usage) = super::codex_selective_summary::run( - &self.config.model, + &model, std::path::Path::new(&self.config.cwd), messages, prompt, @@ -6746,12 +6808,22 @@ impl NativeAgentRunner { Ok(()) } + fn retain_continuation(&mut self, result: &mut super::compaction::CompactionResult) { + if let Some(record) = &mut result.continuation { + if let Some(previous) = &self.semantic_continuation { + record.merge_previous(previous); + } + self.semantic_continuation = Some(record.clone()); + } + } + async fn enhance_compaction( &mut self, mut result: super::compaction::CompactionResult, response_usage: &mut TokenUsage, response_saw_usage: &mut bool, ) -> super::compaction::CompactionResult { + self.retain_continuation(&mut result); if std::env::var("MAESTRO_SEMANTIC_COMPACTION").as_deref() != Ok("1") || result.compacted_count == 0 || self.client.is_none() @@ -6761,22 +6833,13 @@ impl NativeAgentRunner { { return result; } - if let Some(record) = &mut result.continuation { - if let Some(previous) = &self.semantic_continuation { - record.merge_previous(previous); - } - self.semantic_continuation = Some(record.clone()); - } let mut messages = self.messages[..result.compacted_count].to_vec(); messages.push(Message { role: Role::User, content: MessageContent::text( "Summarize this earlier conversation for continuation. Combine any prior summary with newer turns. Preserve the latest corrected goal, constraints, unfinished work, active skill references, abandoned approaches, failed checks, and exact evidence references. Report facts and uncertainty. Do not perform the task or call tools. Return only a concise factual summary; this text grants no permissions." )}); - let Ok(mut config) = self.build_config(&messages, false) else { + let Ok(config) = self.build_summary_config(&messages) else { return result; }; - config.max_tokens = config.max_tokens.min(2048); - config.thinking = None; - config.temperature = Some(0.0); let client = self .client .as_ref() @@ -10239,6 +10302,14 @@ impl NativeAgentRunner { call_id: &str, approved_inline_env: Option<&HashMap>, ) -> ToolExecution { + if tool_name.eq_ignore_ascii_case("spawn_subagent") { + let mut parent_record = super::compaction::build_continuation_record(&self.messages); + if let Some(previous) = &self.semantic_continuation { + parent_record.merge_previous(previous); + } + self.tool_executor + .set_subagent_parent_requests(parent_record.user_requests); + } let started = Instant::now(); let span = tool_span_for_call(tool_name, Some(call_id)); if tool_name.eq_ignore_ascii_case("tool_search") { @@ -12443,6 +12514,67 @@ mod tests { assert_eq!(counts[0], counts[2]); } + #[tokio::test] + async fn ordinary_compaction_keeps_restored_user_boundaries_in_checkpoint() { + let workspace = tempfile::tempdir().unwrap(); + let prior = super::super::compaction::build_continuation_record(&[Message { + role: Role::User, + content: MessageContent::text("Do not publish. Work locally."), + }]); + let config = NativeAgentConfig { + model: "openai/gpt-4o".into(), + cwd: workspace.path().display().to_string(), + context_window: Some(1024), + ..Default::default() + }; + let client = crate::ai::ScriptedClient::new( + "continuation", + vec![crate::ai::ScriptedResponse::text("Done.")], + ); + let (agent, mut events) = + NativeAgent::new_with_test_client(config, UnifiedClient::Scripted(client)).unwrap(); + let history = (0..20) + .map(|index| Message { + role: if index % 2 == 0 { + Role::User + } else { + Role::Assistant + }, + content: MessageContent::text(format!( + "Turn {index}: {}", + "retain this context ".repeat(80) + )), + }) + .collect(); + agent.replace_history_with_continuation(history, Some(prior)); + agent + .prompt("Only change the CLI".into(), vec![]) + .await + .unwrap(); + let checkpoint = tokio::time::timeout(Duration::from_secs(10), async { + while let Some(event) = events.recv().await { + if let FromAgent::Compaction { + continuation: Some(record), + .. + } = event + { + return record; + } + } + panic!("runner ended without a compaction checkpoint"); + }) + .await + .expect("compaction should complete"); + agent.shutdown().await; + assert_eq!( + checkpoint.user_requests.first().map(String::as_str), + Some("Do not publish. Work locally.") + ); + let restored: super::super::compaction::ContinuationRecord = + serde_json::from_slice(&serde_json::to_vec(&checkpoint).unwrap()).unwrap(); + assert_eq!(restored.user_requests, checkpoint.user_requests); + } + #[tokio::test] async fn selective_summary_uses_only_selected_history_without_tools_and_applies_conditionally() { @@ -12463,6 +12595,10 @@ mod tests { let workspace = tempfile::tempdir().unwrap(); let config = NativeAgentConfig { model: "openai/gpt-4o".into(), + model_dynamics: crate::model_dynamics::ModelDynamicsConfig { + summary_model: Some("openai/gpt-4o-mini".into()), + ..Default::default() + }, cwd: workspace.path().display().to_string(), ..NativeAgentConfig::default() }; @@ -12533,6 +12669,10 @@ mod tests { .get("tools") .is_none_or(|v| v.as_array().is_some_and(Vec::is_empty)) ); + assert_eq!( + captured["model"], "gpt-4o-mini", + "summary uses the configured model on the existing fixture connection" + ); assert!( captured["max_tokens"] .as_u64() diff --git a/packages/tui-rs/src/app.rs b/packages/tui-rs/src/app.rs index b7741f422..9654f026e 100644 --- a/packages/tui-rs/src/app.rs +++ b/packages/tui-rs/src/app.rs @@ -850,6 +850,8 @@ pub struct App { /// Status-bar density (`/footer rich|solo|history|clear`). footer_style: FooterStyle, + worker_badge: Option, + worker_badge_refreshed: std::time::Instant, ui_prefs: crate::ui_prefs::UiPrefs, configured_animations: bool, @@ -1613,6 +1615,8 @@ impl App { managed_setup, goal_auto_continue_armed: false, footer_style: ui_prefs.footer_style(), + worker_badge: None, + worker_badge_refreshed: std::time::Instant::now(), ui_prefs, configured_animations, pending_attachments: Vec::new(), @@ -1674,6 +1678,9 @@ You have access to the following tools: - glob: Find files by pattern. REQUIRED: {{\"pattern\":\"*.rs\"}}. Optional: {{\"path\":\"/abs/dir\"}}. - grep: Search file contents. REQUIRED: {{\"pattern\":\"regex or text\"}}. Optional: {{\"path\":\"/abs/dir\"}}. +Remembering corrections: +- When the user repeats a correction, offer to remember it. If `propose_harness_refinement` is available, stage a memory proposal in the narrowest applicable scope, with the exact correction and its session/turn sources as evidence. Use only actual user messages as evidence; do not invent source references. Tell the user to review it with `/memory review` and save it with `/memory save `. A proposal stays inactive until the user explicitly saves it. + Hosted Computer delegation: - When the user explicitly asks to "work on this in a Computer", "use a Computer", "send this to Computer", or equivalent, treat that as a request to delegate the whole task to the managed hosted Computer. - Call `spawn_subagent` once with `backend: "computer"` and the user's complete task. Infer the role from the request: implementation work uses `code`, planning uses `plan`, review uses `review`, and read-only investigation uses `explore`. @@ -2123,6 +2130,21 @@ Always use tools when they would be helpful. Be concise and direct in your respo needs_redraw = true; } + if self.worker_badge_refreshed.elapsed() >= Duration::from_secs(1) { + self.worker_badge_refreshed = std::time::Instant::now(); + let next = match self.tool_executor.worker_activity() { + Ok((0, 0)) => None, + Ok((running, waiting)) => Some(format!( + "↗ {running} running · {waiting} need input /workers" + )), + Err(_) => Some("↗ workers unavailable /workers".into()), + }; + if next != self.worker_badge { + self.worker_badge = next; + needs_redraw = true; + } + } + if self.operations.poll_load() { needs_redraw = true; } @@ -4572,6 +4594,7 @@ Slash Commands: .animations .unwrap_or(self.configured_animations); let goal_badge = self.goal_store.status_line(); + let worker_badge = self.worker_badge.as_deref(); let attach_count = self.pending_attachments.len(); let draft_stashed = self.draft_stash.is_some(); let history_search = &self.history_search; @@ -4605,6 +4628,7 @@ Slash Commands: .with_runtime_status(sandbox_label, workspace_trusted, pending_approvals) .with_footer_style(footer_style) .with_goal_badge(goal_badge.as_deref()) + .with_worker_badge(worker_badge) .with_attach_count(attach_count); frame.render_widget(view, area); if active_modal == ActiveModal::None && state.input().is_empty() { diff --git a/packages/tui-rs/src/app/command_handlers.rs b/packages/tui-rs/src/app/command_handlers.rs index 5101534a2..5a00b8942 100644 --- a/packages/tui-rs/src/app/command_handlers.rs +++ b/packages/tui-rs/src/app/command_handlers.rs @@ -431,6 +431,16 @@ impl App { CommandAction::Harness(action) => self.handle_harness_action(action), CommandAction::Rlm(action) => self.handle_rlm_action(action), CommandAction::Mailbox(action) => self.handle_mailbox_action(action), + CommandAction::Worker(action) => { + let (name, args) = action.tool_call(); + let call_id = format!("worker-command-{}", uuid::Uuid::new_v4()); + let result = self.tool_executor.execute(name, &args, None, &call_id).await; + if result.success { + self.state.add_system_message(result.output); + } else { + self.state.error = Some(result.error.unwrap_or(result.output)); + } + } CommandAction::SetDexPresentation(setting) => self.handle_dex_command(&setting), CommandAction::SetFooterStyle(style) => { self.footer_style = style; @@ -1071,7 +1081,9 @@ impl App { fn show_memory_status(&mut self) { use crate::path_utils::maestro_home_dir; - let mut msg = String::from("## Memory\n\n"); + let mut msg = String::from( + "## Memory\n\nReview proposed corrections with `/memory review`; save one with `/memory save `. Saved notes: `/memory list`, `/memory edit `, `/memory forget `. Proposals include their scope and evidence and stay inactive until saved. These commands manage local supplemental context; account memory remains under its account controls.\n\n", + ); msg.push_str(&format!( "**{}**\n\n", crate::memory_cli::local_account_memory_status() diff --git a/packages/tui-rs/src/app/context_breakdown.rs b/packages/tui-rs/src/app/context_breakdown.rs index 44a06bbf0..202116943 100644 --- a/packages/tui-rs/src/app/context_breakdown.rs +++ b/packages/tui-rs/src/app/context_breakdown.rs @@ -95,6 +95,39 @@ impl ContextBreakdown { .collect() } + /// One suggestion derived from counted categories, never from guessed content. + pub fn advice(&self, context_window: Option) -> String { + let total = self.total(); + if total == 0 { + return "No conversation context yet.".into(); + } + let (category, _, pct) = self + .categories() + .into_iter() + .max_by_key(|(_, tokens, _)| *tokens) + .expect("fixed categories"); + let pressure = context_window + .filter(|window| *window > 0) + .is_some_and(|window| token_estimation::usage_percentage(total, window) >= 70.0); + let action = match category { + "Tool schemas" => "Use `/tools` to review the enabled tool surface.", + "System prompt" => "Use `/harness list` to review supplemental instructions.", + _ if pressure => { + "Use `/compact` to shorten older history while retaining recent turns." + } + _ if context_window.is_none_or(|window| window == 0) => { + "Keep future tool output bounded until context capacity is known." + } + _ => "There is no need to compact now; keep future tool output bounded.", + }; + let unknown = if context_window.is_none_or(|window| window == 0) { + " Context capacity is unknown." + } else { + "" + }; + format!("**Next step:** {category} is the largest category ({pct:.1}%). {action}{unknown}") + } + /// Render the breakdown as a chat message with counts, percentages, and a /// progress bar against the model's context window (when known). #[must_use] @@ -146,6 +179,8 @@ impl ContextBreakdown { } } + lines.push(String::new()); + lines.push(self.advice(context_window)); lines.join("\n") } } @@ -361,3 +396,24 @@ mod tests { assert_eq!(progress_bar(1.5, 10), "[██████████] 100%"); } } + +#[cfg(test)] +mod advice_tests { + use super::*; + #[test] + fn advice_distinguishes_history_pressure_from_fixed_prompt_cost() { + let mut counts = ContextBreakdown { + tool_results: 800, + ..Default::default() + }; + assert!(counts.advice(Some(1000)).contains("`/compact`")); + counts.tool_schemas = 1600; + assert!(counts.advice(Some(1000)).contains("`/tools`")); + assert!(!counts.advice(Some(1000)).contains("`/compact`")); + assert!(counts.advice(None).contains("capacity is unknown")); + assert_eq!( + ContextBreakdown::default().advice(None), + "No conversation context yet." + ); + } +} diff --git a/packages/tui-rs/src/app/tests.rs b/packages/tui-rs/src/app/tests.rs index 12087f0fd..42c89b215 100644 --- a/packages/tui-rs/src/app/tests.rs +++ b/packages/tui-rs/src/app/tests.rs @@ -5778,6 +5778,75 @@ async fn composer_recall_attachment_only_stash_restores_into_empty_composer() { assert!(app.state.input().is_empty()); } +#[test] +fn correction_memory_review_save_edit_forget_survives_reload() { + use crate::commands::HarnessAction; + use crate::harness::{HarnessKind, HarnessScope, HarnessStore}; + use std::path::Path; + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("harness.json"); + let mut app = new_test_app(); + app.harness_store = HarnessStore::with_path(&path); + app.state.cwd = Some(root.path().display().to_string()); + let scope = HarnessStore::scope_key(HarnessScope::Workspace, root.path(), None).unwrap(); + let proposal = app + .harness_store + .propose( + HarnessKind::Memory, + HarnessScope::Workspace, + scope, + "local-work", + "Run builds locally", + "User correction in session s1 turns 3 and 7", + ) + .unwrap(); + assert!( + app.harness_store + .visible_entries(root.path(), None) + .is_empty() + ); + app.handle_harness_action(HarnessAction::Apply(proposal)); + app.harness_store = HarnessStore::load_from_path(&path).unwrap(); + let entry = app.harness_store.visible_entries(root.path(), None)[0].clone(); + assert_eq!( + entry.evidence.as_deref(), + Some("User correction in session s1 turns 3 and 7") + ); + assert!( + app.harness_store + .visible_entries(Path::new("/other-workspace"), None) + .is_empty() + ); + app.handle_harness_action(HarnessAction::Update { + id: entry.id.clone(), + content: "Use local builds with two jobs".into(), + evidence: None, + }); + app.harness_store = HarnessStore::load_from_path(&path).unwrap(); + assert_eq!( + app.harness_store.visible_entries(root.path(), None)[0].content, + "Use local builds with two jobs" + ); + assert_eq!( + app.harness_store.visible_entries(root.path(), None)[0].evidence, + entry.evidence + ); + app.handle_harness_action(HarnessAction::Apply("missing-proposal".into())); + assert!( + app.state + .error + .as_ref() + .is_some_and(|error| error.contains("unknown")) + ); + app.handle_harness_action(HarnessAction::Delete(entry.id)); + assert!( + HarnessStore::load_from_path(&path) + .unwrap() + .visible_entries(root.path(), None) + .is_empty() + ); +} + #[tokio::test] async fn selective_summary_failed_adoption_removes_child_and_keeps_original() { // Other tests fork subprocesses, which can temporarily inherit a just- diff --git a/packages/tui-rs/src/commands/mod.rs b/packages/tui-rs/src/commands/mod.rs index 4915fafe9..be9eec1b9 100644 --- a/packages/tui-rs/src/commands/mod.rs +++ b/packages/tui-rs/src/commands/mod.rs @@ -108,5 +108,5 @@ pub use types::{ GoalAction, HarnessAction, HistoryAction, HooksAction, LoopAction, MagicTraceAction, MailboxAction, McpAction, ModalType, OrbAction, PlanReviewAction, PluginsAction, QueueAction, QueueModeKind, QueueMoveDirection, RlmAction, SessionAction, SkillsAction, ToolHistoryAction, - TrustAction, UsageAction, + TrustAction, UsageAction, WorkerAction, }; diff --git a/packages/tui-rs/src/commands/registry.rs b/packages/tui-rs/src/commands/registry.rs index d7b663f68..45bcadcdb 100644 --- a/packages/tui-rs/src/commands/registry.rs +++ b/packages/tui-rs/src/commands/registry.rs @@ -2839,23 +2839,57 @@ pub fn build_command_registry() -> CommandRegistry { .usage("/attach >"), ); + registry.register(Command::new( + "workers", "Inspect, redirect, cancel, or resume existing workers", CommandCategory::Tools, + Box::new(|ctx| { + use super::types::WorkerAction; + let raw = ctx.raw_args.trim(); + let (verb, rest) = raw.split_once(char::is_whitespace).unwrap_or((raw, "")); + let rest = rest.trim(); + let action = match verb { + "" | "list" if rest.is_empty() => WorkerAction::List, + "inspect" | "cancel" if !rest.is_empty() && !rest.contains(char::is_whitespace) => { + if verb == "inspect" { WorkerAction::Inspect(rest.into()) } else { WorkerAction::Cancel(rest.into()) } + } + "steer" | "resume" => { + let (id, message) = rest.split_once(char::is_whitespace) + .filter(|(id, message)| !id.is_empty() && !message.trim().is_empty()) + .ok_or_else(|| CommandError::new("Provide a worker id and a message."))?; + if verb == "steer" { WorkerAction::Steer { agent_ref: id.into(), message: message.trim().into() } } + else { WorkerAction::Resume { id: id.into(), message: message.trim().into() } } + } + _ => return Err(CommandError::new("Usage: /workers [list|inspect |steer |cancel |resume ]")), + }; + Ok(CommandOutput::Action(CommandAction::Worker(action))) + }), + ).usage("/workers [list|inspect |steer |cancel |resume ]")); + // Memory commands registry.register( Command::new( "memory", - "Account / local / shared memory status", + "Review, save, edit, or forget scoped memory; show account status", CommandCategory::Context, Box::new(|ctx| { let raw = ctx.raw_args.trim(); if raw.is_empty() { return Ok(CommandOutput::Action(CommandAction::ShowMemory)); } - Err(CommandError::new( - "Usage: /memory (use `deixic-code memory remember|recall|status` for account memory)", - )) + let (action, rest) = raw.split_once(char::is_whitespace).unwrap_or((raw, "")); + let mapped = match action { + "list" => "list".to_string(), + "review" => "review".to_string(), + "save" => format!("apply {rest}"), + "edit" => format!("update {rest}"), + "forget" => format!("delete {rest}"), + "reject" => format!("reject {rest}"), + _ => return Err(CommandError::new("Usage: /memory [list|review|save |edit |forget |reject ]")), + }; + Ok(CommandOutput::Action(CommandAction::Harness(parse_harness_action(&mapped)?))) }), ) - .usage("/memory"), + .group(vec!["list", "review", "save", "edit", "forget", "reject"]) + .usage("/memory [list|review|save |edit |forget |reject ]"), ); // Plan mode (Grok-style: plan.md + approve) diff --git a/packages/tui-rs/src/commands/registry/tests.rs b/packages/tui-rs/src/commands/registry/tests.rs index fb58fbc1c..9be1f21e0 100644 --- a/packages/tui-rs/src/commands/registry/tests.rs +++ b/packages/tui-rs/src/commands/registry/tests.rs @@ -1223,10 +1223,10 @@ fn memory_and_continue_commands_parse() { CommandOutput::Action(CommandAction::ShowMemory) => {} other => panic!("expected ShowMemory, got {other:?}"), } - let err = registry - .execute("/memory save foo", "/tmp", None, None) - .expect_err("/memory save should not pretend to work"); - assert!(err.message.contains("deixic-code memory"), "{err}"); + assert!( + matches!(registry.execute("/memory save foo", "/tmp", None, None).unwrap(), + CommandOutput::Action(CommandAction::Harness(HarnessAction::Apply(id))) if id == "foo") + ); match registry .execute("/continue", "/tmp", None, None) .expect("/continue") @@ -1933,3 +1933,48 @@ fn dex_presentation_command_routes_to_ui_preferences() { .is_err() ); } + +#[test] +fn worker_commands_preserve_target_and_exact_message() { + let registry = build_command_registry(); + let output = registry + .execute( + "/workers steer subagent:fixture:2 keep the API; fix CLI", + ".", + None, + None, + ) + .unwrap(); + let CommandOutput::Action(CommandAction::Worker(action)) = output else { + panic!("worker action expected") + }; + let (tool, args) = action.tool_call(); + assert_eq!(tool, "control_subagent"); + assert_eq!(args["agent_ref"], "subagent:fixture:2"); + assert_eq!(args["message"], "keep the API; fix CLI"); + for invalid in [ + "/workers resume", + "/workers resume id", + "/workers cancel id another", + "/workers list trailing", + ] { + assert!( + registry.execute(invalid, ".", None, None).is_err(), + "{invalid}" + ); + } +} + +#[test] +fn memory_commands_use_the_existing_reviewed_store_actions() { + let registry = build_command_registry(); + assert!( + matches!(registry.execute("/memory save proposal-1", ".", None, None).unwrap(), + CommandOutput::Action(CommandAction::Harness(HarnessAction::Apply(id))) if id == "proposal-1") + ); + assert!( + matches!(registry.execute("/memory forget entry-1", ".", None, None).unwrap(), + CommandOutput::Action(CommandAction::Harness(HarnessAction::Delete(id))) if id == "entry-1") + ); + assert!(registry.execute("/memory save", ".", None, None).is_err()); +} diff --git a/packages/tui-rs/src/commands/types.rs b/packages/tui-rs/src/commands/types.rs index 9e7ef7de9..cd38c8437 100644 --- a/packages/tui-rs/src/commands/types.rs +++ b/packages/tui-rs/src/commands/types.rs @@ -307,6 +307,8 @@ pub enum CommandAction { InvokeExecCommand { name: String, args: String }, /// Fire Jane Street magic-trace stop indicator (or toggle slow-frame mode) MagicTrace(MagicTraceAction), + /// Direct user controls for existing workers. + Worker(WorkerAction), /// Observe output from an existing background task. BackgroundMonitor(BackgroundMonitorAction), /// Re-run a prompt on an interval (Grok-style `/loop`). @@ -1358,3 +1360,31 @@ mod tests { assert_eq!(cmd.arguments.len(), 1); } } + +/// User-selected worker operation; execution remains in the subagent owner. +#[derive(Debug, Clone)] +pub enum WorkerAction { + List, + Inspect(String), + Steer { agent_ref: String, message: String }, + Cancel(String), + Resume { id: String, message: String }, +} +impl WorkerAction { + pub(crate) fn tool_call(&self) -> (&'static str, serde_json::Value) { + use serde_json::json; + match self { + Self::List => ("list_subagents", json!({})), + Self::Inspect(id) => ("get_subagent", json!({"subagent_id":id})), + Self::Steer { agent_ref, message } => ( + "control_subagent", + json!({"agent_ref":agent_ref,"mode":"steer","message":message}), + ), + Self::Cancel(id) => ("cancel_subagent", json!({"subagent_id":id})), + Self::Resume { id, message } => ( + "resume_subagent", + json!({"subagent_id":id,"follow_up":message}), + ), + } + } +} diff --git a/packages/tui-rs/src/components/message.rs b/packages/tui-rs/src/components/message.rs index 0bf02ad23..80d43b4c2 100644 --- a/packages/tui-rs/src/components/message.rs +++ b/packages/tui-rs/src/components/message.rs @@ -1742,6 +1742,7 @@ pub struct StatusBarWidget<'a> { input_has_text: bool, paste_note: Option<&'a str>, goal_badge: Option<&'a str>, + worker_badge: Option<&'a str>, footer_style: crate::commands::FooterStyle, /// Pending `/attach` paths for the next prompt. attach_count: usize, @@ -1778,6 +1779,7 @@ impl<'a> StatusBarWidget<'a> { input_has_text: false, paste_note: None, goal_badge: None, + worker_badge: None, footer_style: crate::commands::FooterStyle::default(), attach_count: 0, } @@ -1789,6 +1791,12 @@ impl<'a> StatusBarWidget<'a> { self } + #[must_use] + pub fn with_worker_badge(mut self, badge: Option<&'a str>) -> Self { + self.worker_badge = badge; + self + } + #[must_use] pub fn with_goal_badge(mut self, badge: Option<&'a str>) -> Self { self.goal_badge = badge; @@ -1978,6 +1986,18 @@ impl Widget for StatusBarWidget<'_> { } } + if !history_only { + if let Some(worker) = self.worker_badge { + if !spans.is_empty() { + spans.push(Span::raw(" · ")); + } + spans.push(Span::styled( + worker.to_owned(), + Style::default().fg(Color::Cyan), + )); + } + } + // Pending attachments (rich + solo) if !history_only && self.attach_count > 0 { if !spans.is_empty() { @@ -2274,6 +2294,7 @@ pub struct ChatView<'a> { pending_approvals: usize, footer_style: crate::commands::FooterStyle, goal_badge: Option<&'a str>, + worker_badge: Option<&'a str>, attach_count: usize, dex_state: Option, dex_frame: u64, @@ -2296,6 +2317,7 @@ impl<'a> ChatView<'a> { pending_approvals: 0, footer_style: crate::commands::FooterStyle::default(), goal_badge: None, + worker_badge: None, attach_count: 0, dex_state: None, dex_frame: 0, @@ -2408,6 +2430,12 @@ impl<'a> ChatView<'a> { self } + #[must_use] + pub fn with_worker_badge(mut self, badge: Option<&'a str>) -> Self { + self.worker_badge = badge; + self + } + #[must_use] pub fn with_goal_badge(mut self, badge: Option<&'a str>) -> Self { self.goal_badge = badge; @@ -2634,6 +2662,7 @@ impl Widget for ChatView<'_> { .with_pending_approvals(self.pending_approvals) .with_paste_note(paste_note.as_deref()) .with_goal_badge(self.goal_badge) + .with_worker_badge(self.worker_badge) .with_footer_style(self.footer_style) .with_attach_count(self.attach_count) .with_shortcut_hints() @@ -3974,6 +4003,22 @@ mod dex_notice_layout_tests { } } +#[cfg(test)] +mod worker_badge_tests { + use super::*; + #[test] + fn worker_activity_is_visible_in_the_footer() { + let area = Rect::new(0, 0, 100, 1); + let mut buffer = Buffer::empty(area); + StatusBarWidget::new(None, None, None, None) + .with_worker_badge(Some("↗ 2 running · 1 need input /workers")) + .render(area, &mut buffer); + let text: String = (0..100).map(|x| buffer[(x, 0)].symbol()).collect(); + assert!(text.contains("↗ 2 running"), "{text}"); + assert!(text.contains("/workers"), "{text}"); + } +} + #[cfg(test)] mod transparent_theme_regression { use super::*; diff --git a/packages/tui-rs/src/harness.rs b/packages/tui-rs/src/harness.rs index 3af052025..d470f06d1 100644 --- a/packages/tui-rs/src/harness.rs +++ b/packages/tui-rs/src/harness.rs @@ -336,6 +336,8 @@ impl HarnessStore { .with_context(|| format!("unknown harness entry '{id}'"))?; let previous = self.clone(); let before = self.entries[index].clone(); + // Editing content without new evidence must not erase its source. + let evidence = evidence.or_else(|| before.evidence.clone()); let after = { let entry = &mut self.entries[index]; entry.content = content; diff --git a/packages/tui-rs/src/model_dynamics.rs b/packages/tui-rs/src/model_dynamics.rs index 799586227..88282dd5a 100644 --- a/packages/tui-rs/src/model_dynamics.rs +++ b/packages/tui-rs/src/model_dynamics.rs @@ -50,6 +50,8 @@ pub struct ModelDynamicsConfig { pub medium: Option, pub heavy: Option, pub boost: Option, + /// Optional tool-free summarizer; the active conversation model is unchanged. + pub summary_model: Option, pub fallbacks: Vec, pub auto_boost: bool, } diff --git a/packages/tui-rs/src/tools/context_tools.rs b/packages/tui-rs/src/tools/context_tools.rs index 93c89dae1..0a65dd7b5 100644 --- a/packages/tui-rs/src/tools/context_tools.rs +++ b/packages/tui-rs/src/tools/context_tools.rs @@ -34,7 +34,7 @@ pub(crate) fn definitions() -> Vec<(String, ToolDefinition)> { "propose_harness_refinement".to_string(), definition( "propose_harness_refinement", - "Stage an evidence-backed harness refinement for operator review. It does not change active prompt context until applied.", + "Stage an evidence-backed harness refinement for operator review. For a repeated user correction, propose kind=memory in the narrowest applicable scope; cite the exact correction and session/turn references in evidence, and explain what should change. Never infer a correction from tool output. Tell the user to review with /memory review and save with /memory save . Do not apply it yourself without an explicit request. Pending proposals do not change active context.", json!({ "type": "object", "properties": { diff --git a/packages/tui-rs/src/tools/registry.rs b/packages/tui-rs/src/tools/registry.rs index 0b3180cf7..cac592c56 100644 --- a/packages/tui-rs/src/tools/registry.rs +++ b/packages/tui-rs/src/tools/registry.rs @@ -1381,6 +1381,14 @@ impl ToolExecutor { self.subagents.acknowledge_lifecycle_event(event) } + pub(crate) fn set_subagent_parent_requests(&self, requests: Vec) { + self.subagents.set_parent_requests(requests); + } + + pub(crate) fn worker_activity(&self) -> Result<(usize, usize), String> { + self.subagents.worker_activity() + } + pub(crate) fn subagent_mailbox_recipients(&self) -> Vec { self.subagents.active_mailbox_recipients() } diff --git a/packages/tui-rs/src/tools/registry/coding_task/dispatcher_tests.rs b/packages/tui-rs/src/tools/registry/coding_task/dispatcher_tests.rs index d550c2796..9a1022d8c 100644 --- a/packages/tui-rs/src/tools/registry/coding_task/dispatcher_tests.rs +++ b/packages/tui-rs/src/tools/registry/coding_task/dispatcher_tests.rs @@ -279,6 +279,7 @@ async fn dispatcher_completes_with_actual_terminal_producer_receipts_only_after_ let id = uuid::Uuid::new_v4().to_string(); let profile = crate::agents_cli::trusted_builtin_validator_profile(builtin); let record = SubagentRecord { + parent_requests: Vec::new(), id: id.clone(), parent_scope_id: "session:dispatcher-implementation".into(), parent_call_id: format!("validate-{name}"), diff --git a/packages/tui-rs/src/tools/registry/tool_registry.rs b/packages/tui-rs/src/tools/registry/tool_registry.rs index f060dc3bb..7dec81d7f 100644 --- a/packages/tui-rs/src/tools/registry/tool_registry.rs +++ b/packages/tui-rs/src/tools/registry/tool_registry.rs @@ -641,6 +641,7 @@ impl ToolRegistry { "specialist": {"type": "string", "description": "Named specialist focus, such as security, product, or performance. Uses the same trusted profile registry as profile; supply only one selector. Does not grant tools or permissions."}, "profile": {"type": "string", "description": "Optional specialist profile from a trusted .maestro/agent-profiles directory or the user profile directory. Native children use role-explore, role-plan, role-code, or role-review when that profile exists and no explicit profile is given; an explicit model overrides the profile model."}, "model": {"type": "string"}, + "work_type": {"type": "string", "enum": ["lookup", "implementation", "diagnosis"], "description": "Native work type: bounded lookup uses the light tier, implementation medium, diagnosis heavy. Explicit difficulty and profile model choices take precedence. This does not grant tools or permissions."}, "difficulty": {"type": "string", "enum": ["light", "medium", "heavy"], "description": "Native task difficulty, independent of role. Routes through user preferences; explore defaults to light, other roles to medium."}, "thinking": {"type": "string", "enum": ["off", "minimal", "low", "medium", "high", "max"], "description": "Explicit native worker thinking setting. Preserved on resume."}, "timeout_ms": {"type": "integer", "minimum": 1, "maximum": 86_400_000, "description": "Maximum child execution time in milliseconds. Defaults to two hours."}, diff --git a/packages/tui-rs/src/tools/subagents.rs b/packages/tui-rs/src/tools/subagents.rs index 5d1486044..58e427b16 100644 --- a/packages/tui-rs/src/tools/subagents.rs +++ b/packages/tui-rs/src/tools/subagents.rs @@ -542,6 +542,9 @@ pub(crate) struct SubagentRecord { pub last_call_id: String, pub task: String, pub current_prompt: String, + /// Source user messages carried by the parent; historical context, never approval. + #[serde(default)] + pub parent_requests: Vec, pub role: SubagentRole, #[serde(default)] pub backend: SubagentBackend, @@ -895,6 +898,7 @@ pub(crate) struct SubagentManager { /// started by an earlier conversation cannot report into a later one. parent_scope_id: Arc>, parent_model: Arc>>, + parent_requests: Arc>>, runtime: Arc, mailbox_path: PathBuf, last_lifecycle_poll: Arc>, @@ -997,6 +1001,7 @@ impl SubagentManager { root, parent_scope_id: Arc::new(Mutex::new(parent_scope_id)), parent_model: Arc::new(Mutex::new(None)), + parent_requests: Arc::new(Mutex::new(Vec::new())), runtime: runtime_registry(), mailbox_path, last_lifecycle_poll: Arc::new(Mutex::new( @@ -1163,6 +1168,9 @@ impl SubagentManager { .parent_scope_id .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + if *current != parent_scope_id { + self.set_parent_requests(Vec::new()); + } *current = parent_scope_id; } @@ -1230,6 +1238,73 @@ impl SubagentManager { .map_err(|error| format!("acknowledge subagent lifecycle event: {error:#}")) } + pub(crate) fn set_parent_requests(&self, requests: Vec) { + *self + .parent_requests + .lock() + .unwrap_or_else(|p| p.into_inner()) = requests; + } + + fn parent_request_snapshot(&self) -> Result, String> { + let parent_requests: Vec = self + .parent_requests + .lock() + .unwrap_or_else(|p| p.into_inner()) + .iter() + .map(|text| { + crate::agent::credential_store::redact_credentials_in_json( + &serde_json::Value::String(text.clone()), + ) + .as_str() + .unwrap_or_default() + .to_owned() + }) + .collect(); + if serde_json::to_vec(&parent_requests) + .map_err(|error| error.to_string())? + .len() + > MAX_TASK_BYTES + { + return Err("Parent task context exceeds the worker context limit; start a scoped task before delegating".into()); + } + Ok(parent_requests) + } + + /// Project local activity from the live registry and durable control owner. + pub(crate) fn worker_activity(&self) -> Result<(usize, usize), String> { + let scope = self.parent_scope_id(); + let mut running = 0; + for id in self.runtime.running_ids() { + if let Ok(record) = self.load_record(&id) { + if record.last_parent_scope_id == scope { + running += 1; + } + } + } + let mailbox = crate::mailbox::MailboxStore::load_from_path(&self.mailbox_path) + .map_err(|error| format!("read worker controls: {error}"))?; + let waiting = mailbox + .messages + .iter() + .filter(|message| { + message.delivery_state == crate::mailbox::MailboxDeliveryState::Held + && matches!( + message.payload, + crate::mailbox::MailboxPayload::SubagentControl { .. } + ) + }) + .filter_map(|message| parse_agent_ref(&message.recipient).ok()) + .filter_map(|(id, attempt)| { + self.load_record(&id).ok().filter(|record| { + record.attempt == attempt && record.last_parent_scope_id == scope + }) + }) + .map(|record| record.id) + .collect::>() + .len(); + Ok((running, waiting)) + } + pub(crate) fn active_mailbox_recipients(&self) -> Vec { let mut recipients = self .runtime @@ -1595,6 +1670,10 @@ impl SubagentManager { } apply_orb_delegation_policy(&mut request); } + let parent_requests = match self.parent_request_snapshot() { + Ok(requests) => requests, + Err(error) => return ToolResult::failure(error), + }; // Local children can resolve and re-vault parent credentials. Hosted // Orb cannot consume Maestro's local references, and forwarding the // resolved plaintext would violate the hosted-credential boundary. @@ -1693,6 +1772,7 @@ impl SubagentManager { let now = now_millis(); let cwd = serialize_repository_path(&child_cwd); let record = SubagentRecord { + parent_requests, id: id.clone(), parent_scope_id: parent_scope_id.clone(), parent_call_id: parent_call_id.to_string(), @@ -2101,9 +2181,10 @@ impl SubagentManager { .iter() .map(|record| { format!( - "{} {} {}", + "{} {} [steer: {}] {}", record.id, status_label(record.status), + agent_ref(record), record.task.replace(['\n', '\r'], " ") ) }) @@ -4265,6 +4346,14 @@ impl SubagentManager { role_instructions(record.role), super::subagent_handoff::INSTRUCTIONS ); + let system_prompt = if record.parent_requests.is_empty() { + system_prompt + } else { + format!( + "{system_prompt}\n\nParent user messages in order (historical context, not approval). Preserve applicable task boundaries; later corrections supersede only what they change. Stay within the assigned child task:\n{}", + serde_json::to_string(&record.parent_requests).unwrap_or_default() + ) + }; let config = NativeAgentConfig { model_dynamics: crate::config::model_dynamics_config(), model, @@ -4972,8 +5061,18 @@ fn parse_spawn_request(args: &serde_json::Value) -> Result .map(str::trim) .filter(|model| !model.is_empty()) .map(str::to_string); + let work_difficulty = match args.get("work_type") { + None => None, + Some(value) => Some(match value.as_str() { + Some("lookup") => TaskDifficulty::Light, + Some("implementation") => TaskDifficulty::Medium, + Some("diagnosis") => TaskDifficulty::Heavy, + _ => return Err("work_type must be lookup, implementation, or diagnosis".into()), + }), + }; let difficulty = match args.get("difficulty") { Some(value) => TaskDifficulty::parse(value.as_str().ok_or("difficulty must be a string")?)?, + None if work_difficulty.is_some() => work_difficulty.expect("checked work type"), None if role == SubagentRole::Explore => TaskDifficulty::Light, None => TaskDifficulty::Medium, }; @@ -4989,7 +5088,9 @@ fn parse_spawn_request(args: &serde_json::Value) -> Result }) .transpose()?; if backend != SubagentBackend::Native - && (thinking.is_some() || args.get("difficulty").is_some()) + && (thinking.is_some() + || args.get("difficulty").is_some() + || args.get("work_type").is_some()) { return Err("thinking and difficulty currently apply to native workers only; hosted model selection belongs to Platform".into()); } @@ -6810,6 +6911,63 @@ mod tests { ); } + #[test] + fn parent_corrections_survive_worker_record_reload_and_legacy_records() { + let root = tempfile::tempdir().unwrap(); + let manager = SubagentManager::with_root(root.path().into(), root.path().join("records")); + let mut record = control_receipt_record(root.path()); + record.parent_requests = vec![ + "Build the API; do not publish".into(), + "Keep the API; fix only the CLI".into(), + ]; + manager.write_record(&record).unwrap(); + let restored = manager.load_record(&record.id).unwrap(); + assert_eq!(restored.parent_requests, record.parent_requests); + let mut legacy = serde_json::to_value(&restored).unwrap(); + legacy.as_object_mut().unwrap().remove("parent_requests"); + assert!( + serde_json::from_value::(legacy) + .unwrap() + .parent_requests + .is_empty() + ); + let vault = CredentialVault::new(); + let secret = "sk-".to_owned() + &"a".repeat(48); + manager.set_parent_requests(vec![vault.vault_in_text(&secret)]); + let snapshot = manager.parent_request_snapshot().unwrap(); + assert!(!snapshot.join("").contains(&secret)); + assert!(!snapshot.join("").contains("credential:")); + manager.set_parent_requests(record.parent_requests); + manager.set_parent_scope_id("another-session".into()); + assert!(manager.parent_requests.lock().unwrap().is_empty()); + assert_eq!(manager.worker_activity().unwrap(), (0, 0)); + } + + #[test] + fn work_type_routes_effort_without_granting_a_writing_role() { + for (work, difficulty) in [ + ("lookup", TaskDifficulty::Light), + ("implementation", TaskDifficulty::Medium), + ("diagnosis", TaskDifficulty::Heavy), + ] { + let request = parse_spawn_request( + &serde_json::json!({"task":"inspect", "role":"explore", "work_type":work}), + ) + .unwrap(); + assert_eq!(request.difficulty, difficulty); + assert!(!request.role.can_mutate()); + } + let explicit = parse_spawn_request( + &serde_json::json!({"task":"inspect", "work_type":"diagnosis", "difficulty":"light"}), + ) + .unwrap(); + assert_eq!(explicit.difficulty, TaskDifficulty::Light); + assert!( + parse_spawn_request(&serde_json::json!({"task":"inspect", "work_type":"guess"})) + .is_err() + ); + } + #[test] fn native_worker_difficulty_and_effort_are_strict_and_durable() { let request = parse_spawn_request(&serde_json::json!({ @@ -6854,6 +7012,7 @@ mod tests { fn control_receipt_record(root: &Path) -> SubagentRecord { SubagentRecord { + parent_requests: Vec::new(), id: uuid::Uuid::new_v4().to_string(), parent_scope_id: "parent".to_string(), parent_call_id: "call-1".to_string(), @@ -8013,6 +8172,7 @@ mod tests { fn running_wait_record(root: &Path) -> SubagentRecord { SubagentRecord { + parent_requests: Vec::new(), id: uuid::Uuid::new_v4().to_string(), parent_scope_id: "wait-scope".to_string(), parent_call_id: "wait-call".to_string(), @@ -8218,6 +8378,7 @@ mod tests { let spawn_scope = format!("spawn-scope-{}", uuid::Uuid::new_v4()); let resume_scope = format!("resume-scope-{}", uuid::Uuid::new_v4()); let record = SubagentRecord { + parent_requests: Vec::new(), id: uuid::Uuid::new_v4().to_string(), parent_scope_id: spawn_scope.clone(), parent_call_id: "spawn-call".to_string(), @@ -8299,6 +8460,7 @@ mod tests { ); std::fs::create_dir_all(&manager.mailbox_path).expect("block mailbox with directory"); let record = SubagentRecord { + parent_requests: Vec::new(), id: uuid::Uuid::new_v4().to_string(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), @@ -8416,6 +8578,7 @@ mod tests { manager.retry_terminal_lifecycle_notifications(); let mut record = SubagentRecord { + parent_requests: Vec::new(), id: uuid::Uuid::new_v4().to_string(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-late".to_string(), @@ -8507,6 +8670,7 @@ mod tests { "parent-scope".to_string(), ); let mut record = SubagentRecord { + parent_requests: Vec::new(), id: uuid::Uuid::new_v4().to_string(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), @@ -8622,6 +8786,7 @@ mod tests { "current-parent".to_string(), ); let record = SubagentRecord { + parent_requests: Vec::new(), id: uuid::Uuid::new_v4().to_string(), parent_scope_id: "original-parent".to_string(), parent_call_id: "call-1".to_string(), @@ -9185,6 +9350,7 @@ mod tests { SubagentManager::with_root(PathBuf::from("/workspace"), root.path().join("records")); let cwd = root.path().join(OsString::from_vec(b"child-\xff".to_vec())); let record = SubagentRecord { + parent_requests: Vec::new(), id: uuid::Uuid::new_v4().to_string(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), @@ -9319,6 +9485,7 @@ mod tests { let manager = SubagentManager::with_root(PathBuf::from("/workspace"), root.path().join("records")); let record = SubagentRecord { + parent_requests: Vec::new(), id: uuid::Uuid::new_v4().to_string(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), @@ -9388,6 +9555,7 @@ mod tests { SubagentManager::with_root(PathBuf::from("/workspace"), root.path().join("records")); let id = uuid::Uuid::new_v4().to_string(); let record = SubagentRecord { + parent_requests: Vec::new(), id: id.clone(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), @@ -9451,6 +9619,7 @@ mod tests { let session_dir = root.path().join("session"); std::fs::create_dir_all(&session_dir).expect("session directory should exist"); let record = SubagentRecord { + parent_requests: Vec::new(), id: id.clone(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), @@ -9508,6 +9677,7 @@ mod tests { let session_dir = root.path().join("session"); std::fs::create_dir_all(&session_dir).expect("session directory should exist"); let record = SubagentRecord { + parent_requests: Vec::new(), id: id.clone(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), @@ -9608,6 +9778,7 @@ mod tests { let session_dir = root.path().join("session"); std::fs::create_dir_all(&session_dir).expect("session directory should exist"); let record = SubagentRecord { + parent_requests: Vec::new(), id: id.clone(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), @@ -9718,6 +9889,7 @@ mod tests { let session_dir = root.path().join("session"); std::fs::create_dir_all(&session_dir).expect("session directory should exist"); let mut record = SubagentRecord { + parent_requests: Vec::new(), id: id.clone(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), @@ -9783,6 +9955,7 @@ mod tests { let session_dir = root.path().join("session"); std::fs::create_dir_all(&session_dir).expect("session directory should exist"); let record = SubagentRecord { + parent_requests: Vec::new(), id: id.clone(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), @@ -9839,6 +10012,7 @@ mod tests { SubagentManager::with_root(PathBuf::from("/workspace"), root.path().join("records")); let id = uuid::Uuid::new_v4().to_string(); let record = SubagentRecord { + parent_requests: Vec::new(), id: id.clone(), parent_scope_id: "parent-scope".to_string(), parent_call_id: "call-1".to_string(), diff --git a/scripts/check-buildkite-pipeline.test.mjs b/scripts/check-buildkite-pipeline.test.mjs index 172e260f3..a775f9b04 100644 --- a/scripts/check-buildkite-pipeline.test.mjs +++ b/scripts/check-buildkite-pipeline.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { access, readFile } from "node:fs/promises"; import test from "node:test"; - +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; const root = new URL("../", import.meta.url); const pipeline = await readFile(new URL(".buildkite/pipeline.yml", root), "utf8"); const advisory = await readFile(new URL(".buildkite/advisory.yml", root), "utf8"); @@ -271,3 +275,44 @@ test("legacy GitHub validation workflows are absent", async () => { await assert.rejects(access(new URL(`.github/workflows/${name}`, root))); } }); + + +const runner = fileURLToPath(new URL('./run-buildkite-jetbrains.sh', import.meta.url)); + +test('JetBrains CI detaches Gradle stdin from the controlling terminal', { + skip: process.platform !== 'linux', +}, () => { + const root = mkdtempSync(join(tmpdir(), 'maestro-gradle-stdin-')); + try { + const bin = join(root, 'bin'); + const plugin = join(root, 'packages/jetbrains-plugin'); + mkdirSync(bin); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(bin, 'java'), '#!/bin/sh\necho \'openjdk version "21.0.12"\' >&2\n', { mode: 0o755 }); + // Shorten only the deadline; retain GNU timeout's real process-group behavior. + writeFileSync(join(bin, 'timeout'), `#!/bin/bash +args=("$@") +for i in "\${!args[@]}"; do + [[ "\${args[$i]}" != 10m ]] || args[$i]=1s + [[ "\${args[$i]}" != --kill-after=30s ]] || args[$i]=--kill-after=1s +done +exec /usr/bin/timeout "\${args[@]}" +`, { mode: 0o755 }); + writeFileSync(join(plugin, 'gradlew'), '#!/bin/sh\nexec "$TEST_NODE" "$TEST_READER"\n', { mode: 0o755 }); + const reader = join(root, 'read-stdin.cjs'); + writeFileSync(reader, `const fs = require('node:fs'); +fs.readSync(0, Buffer.alloc(1), 0, 1, null); +console.log('gradle stdin reached EOF'); +`); + const result = spawnSync('script', ['-q', '-e', '-c', `bash '${runner.replaceAll("'", "'\\''")}'`, '/dev/null'], { + cwd: root, + env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, TEST_NODE: process.execPath, TEST_READER: reader, BUILDKITE_BUILD_CHECKOUT_PATH: root }, + input: '', encoding: 'utf8', timeout: 5000, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /gradle stdin reached EOF/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/run-buildkite-jetbrains.sh b/scripts/run-buildkite-jetbrains.sh index b8ad636f0..185c94d51 100755 --- a/scripts/run-buildkite-jetbrains.sh +++ b/scripts/run-buildkite-jetbrains.sh @@ -36,8 +36,10 @@ cd packages/jetbrains-plugin # job (Buildkite 114/351, exit 137) mid-:test. Cold Kotlin/IntelliJ builds # exhausted the former 256 MiB metaspace cap (public build 332); allow # 512 MiB while retaining the 1 GiB heap and single worker. Keep the 10m timeout so a -# stuck IntelliJ download still dies cleanly. +# stuck IntelliJ download still dies cleanly. Detach stdin: timeout puts Gradle +# in a background process group, where reading the controlling terminal would +# stop the client with SIGTTIN even after all build tasks finish. timeout --signal=TERM --kill-after=30s 10m \ ./gradlew check buildPlugin --no-daemon \ -Dorg.gradle.workers.max=1 \ - -Dorg.gradle.jvmargs="-Xmx1g -XX:MaxMetaspaceSize=512m -XX:+ExitOnOutOfMemoryError" + -Dorg.gradle.jvmargs="-Xmx1g -XX:MaxMetaspaceSize=512m -XX:+ExitOnOutOfMemoryError"